feat: Add workbench and major cleanup, refactor, and updates (#21)

* FreeCAD addon support for Workbench and Plugins

* docs: refactor docs and clean up linters, etc.

* Remove mdformat

* test: improve test coverage

* test:Lots of general fixes

* chore: more general fixes
This commit is contained in:
Sean P. Kane
2026-01-06 15:44:27 -08:00
committed by GitHub
parent 4344ff7536
commit 6a6cad9e65
64 changed files with 8317 additions and 2245 deletions
+28 -2
View File
@@ -64,9 +64,9 @@ reviews:
- Proper docstrings for tool discovery
- Consistent error handling patterns
- GUI-safe checks (FreeCAD.GuiUp) for view operations
- path: "src/freecad_mcp/freecad_plugin/**/*.py"
- path: "addon/FreecadRobustMCP/**/*.py"
instructions: >
This code runs inside FreeCAD's Python environment.
This code runs inside FreeCAD's Python environment as a workbench addon.
It cannot import packages from the project's virtualenv (mcp, pydantic).
Watch for accidental imports of project dependencies.
- path: "macros/**/*.FCMacro"
@@ -96,6 +96,13 @@ reviews:
CRITICAL: Python 3.11 is required - FreeCAD bundles libpython3.11.
Using a different Python version causes ABI incompatibility crashes.
INTENTIONAL PATTERN - Dependency version ranges:
- Dependencies use '>=' minimum version constraints (e.g., pydantic>=2.0)
- This is correct for Python libraries per PEP 440 best practices
- Exact versions are pinned in uv.lock for reproducible builds
- Do NOT suggest changing '>=' to '==' - this would break library usability
- The combination of '>=' in pyproject.toml + uv.lock is the modern standard
- path: "README.md"
instructions: >
INTENTIONAL NAMING: The repo is "freecad-robust-mcp-and-more" but Docker
@@ -114,6 +121,25 @@ reviews:
This is consistent and intentional. Do not flag as inconsistent pinning.
CRITICAL: Python must stay at 3.11 to match FreeCAD's bundled Python.
- path: "justfile"
instructions: >
Main task runner configuration using just (https://just.systems/).
Imports modules from the just/ directory.
- path: "just/*.just"
instructions: >
Just module files for task automation.
INTENTIONAL PATTERN - Heredoc indentation:
- Heredoc content inside recipes IS indented with 4 spaces (matching recipe body)
- This is CORRECT - just automatically strips leading indentation from heredocs
- Do NOT suggest adding more indentation to heredoc content
- Do NOT suggest removing indentation from heredoc content
- The 4-space indent prevents just from parsing embedded code as justfile syntax
- Example: Python code in heredocs uses 4-space indent, just outputs it unindented
INTENTIONAL PATTERN - Module paths:
- Modules use `project_root := justfile_directory()` to get project root
- Do NOT suggest using $(pwd) - it returns the wrong directory in modules
# Tools to use for analysis
tools:
-1
View File
@@ -13,7 +13,6 @@ ruff
mypy
bandit
codespell
mdformat
commitizen
hadolint
shellcheck
+2 -2
View File
@@ -209,8 +209,8 @@ jobs:
Before using this Docker image, start FreeCAD with the MCP bridge:
1. Open FreeCAD
2. Go to **Macro → Macros → StartMCPBridge** (or run from Python console)
1. Install the **MCP Bridge** workbench via FreeCAD Addon Manager
2. Switch to the MCP Bridge workbench and click **Start MCP Bridge**
3. The bridge will start listening on ports 9875 (XML-RPC) and 9876 (Socket)
## Full Documentation
+18 -12
View File
@@ -1,21 +1,23 @@
name: Macro Tests
name: Integration Tests
on:
push:
branches: [main, master]
paths:
- "src/freecad_mcp/**/*.py"
- "macros/**/*.FCMacro"
- "macros/**/*.py"
- "tests/integration/test_cut_object_for_magnets.py"
- "tests/integration/test_multi_export.py"
- "addon/FreecadRobustMCP/**/*.py"
- "tests/integration/**/*.py"
- ".github/workflows/macro-test.yaml"
pull_request:
branches: [main, master]
paths:
- "src/freecad_mcp/**/*.py"
- "macros/**/*.FCMacro"
- "macros/**/*.py"
- "tests/integration/test_cut_object_for_magnets.py"
- "tests/integration/test_multi_export.py"
- "addon/FreecadRobustMCP/**/*.py"
- "tests/integration/**/*.py"
- ".github/workflows/macro-test.yaml"
# Cancel in-progress runs for the same branch
@@ -24,8 +26,8 @@ concurrency:
cancel-in-progress: true
jobs:
test-macros:
name: Test FreeCAD Macros
test-integration:
name: Integration Tests with FreeCAD
runs-on: ubuntu-latest
steps:
@@ -117,14 +119,11 @@ jobs:
- name: Start FreeCAD headless with MCP bridge
run: |
# Set up environment
export PYTHONPATH="${PWD}/src:${PYTHONPATH:-}"
echo "Using FreeCAD: freecadcmd"
# Start FreeCAD headless with MCP bridge in background
# Capture stdout to a file so we can extract the instance ID
freecadcmd src/freecad_mcp/freecad_plugin/headless_server.py > /tmp/freecad_bridge.log 2>&1 &
# Uses the workbench addon's headless server script
freecadcmd addon/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py > /tmp/freecad_bridge.log 2>&1 &
FREECAD_PID=$!
echo "FREECAD_PID=$FREECAD_PID" >> "$GITHUB_ENV"
@@ -173,6 +172,13 @@ jobs:
run: |
uv run pytest tests/integration/test_multi_export.py -v --tb=short
- name: Run headless mode integration tests
env:
FREECAD_MODE: xmlrpc
# Tests for headless FreeCAD operations (primitives, booleans, exports, etc.)
run: |
uv run pytest tests/integration/test_headless_mode.py -v --tb=short
- name: Stop FreeCAD
if: always()
run: |
+2
View File
@@ -46,4 +46,6 @@ jobs:
# - no-commit-to-branch: Always fails in CI (we're on main/master)
# - trufflehog: Has wasm/go-re2 panic bug in GitHub Actions environment
SKIP: no-commit-to-branch,trufflehog
# Safety CLI API key for dependency vulnerability scanning
SAFETY_API_KEY: ${{ secrets.SAFETY_API_KEY }}
run: uv run pre-commit run --all-files --show-diff-on-failure
+3 -1
View File
@@ -5,6 +5,7 @@ on:
branches: [main, master]
paths:
- "src/**/*.py"
- "addon/**/*.py"
- "tests/**/*.py"
- "pyproject.toml"
- "uv.lock"
@@ -13,6 +14,7 @@ on:
branches: [main, master]
paths:
- "src/**/*.py"
- "addon/**/*.py"
- "tests/**/*.py"
- "pyproject.toml"
- "uv.lock"
@@ -50,6 +52,6 @@ jobs:
- name: Run type checking
run: uv run mypy src/
# Note: FreeCAD integration tests are handled by the "Macro Tests" workflow
# Note: FreeCAD integration tests are handled by the "Integration Tests" workflow
# (macro-test.yaml) which runs on every PR. That workflow sets up FreeCAD
# AppImage and runs tests/integration/ tests with proper headless configuration.
+14 -5
View File
@@ -7,11 +7,20 @@
python = "3.11"
# Pin tool versions for reproducible CI builds
# Update these periodically with: mise upgrade
uv = "0.9" # uv package manager
just = "1.43" # task runner
pre-commit = "4.5" # pre-commit hooks
github-cli = "2.74" # GitHub CLI for PR/issue management
trivy = "0.62" # container vulnerability scanner
uv = "0.9" # uv package manager
just = "1.43" # task runner
pre-commit = "4.5" # pre-commit hooks
github-cli = "2.74" # GitHub CLI for PR/issue management
# Security and code quality tools
trivy = "0.62" # container vulnerability scanner
gitleaks = "8.30" # secrets scanner
hadolint = "2.14" # Dockerfile linter
shellcheck = "0.11" # shell script linter
actionlint = "1.7" # GitHub Actions linter
# Markdown tools
markdownlint-cli2 = "0.20" # markdown linter
[env]
# FreeCAD connection mode:
+58 -17
View File
@@ -14,6 +14,8 @@ repos:
- id: trailing-whitespace
exclude: \.md$ # Allow trailing spaces in markdown for line breaks
- id: end-of-file-fixer
exclude: \.safety-project\.ini$ # Safety CLI manages its own formatting
- id: check-xml
- id: check-yaml
args: [--unsafe]
- id: check-toml
@@ -75,6 +77,19 @@ repos:
types: [text]
files: \.(py|FCMacro)$
# Safety - Dependency vulnerability scanning
# Checks installed packages against known security vulnerabilities
# Local: Requires free safetycli.com account. Run `uv run safety auth` first.
# CI: Uses SAFETY_API_KEY secret passed via environment variable.
- repo: local
hooks:
- id: safety
name: safety (dependency vulnerabilities)
entry: uv run safety scan --detailed-output
language: system
pass_filenames: false
files: ^(pyproject\.toml|uv\.lock)$
# ==========================================================================
# Secrets Detection - Multi-Layer Approach
# ==========================================================================
@@ -126,31 +141,17 @@ repos:
exclude: '(^|/)uv\.lock$|\.secrets\.baseline$'
# ==========================================================================
# Markdown Linting - Comprehensive Configuration
# Markdown Linting
# ==========================================================================
# Primary: markdownlint-cli2 - Comprehensive markdown linter
# markdownlint-cli2 - Comprehensive markdown linter with auto-fix
# Config: .markdownlint.yaml
- repo: https://github.com/DavidAnson/markdownlint-cli2
rev: v0.17.1
hooks:
- id: markdownlint-cli2
name: markdownlint (linter)
args: [] # Uses .markdownlint.yaml automatically
# Secondary: mdformat - Opinionated markdown formatter
- repo: https://github.com/executablebooks/mdformat
rev: 0.7.21
hooks:
- id: mdformat
name: mdformat (formatter)
additional_dependencies:
- mdformat-gfm # GitHub Flavored Markdown
- mdformat-frontmatter # YAML front matter
- mdformat-footnote # Footnotes
- mdformat-tables # Table formatting
- mdformat-simple-breaks # Use --- for horizontal rules
exclude: CHANGELOG\.md$ # Don't format auto-generated changelogs
args: [--fix]
# Tertiary: md-toc - Table of contents generator
# Automatically updates TOC between <!--TOC--> markers
@@ -236,6 +237,18 @@ repos:
- "1"
- .
# ==========================================================================
# Documentation Build Validation
# ==========================================================================
- repo: local
hooks:
- id: mkdocs-build
name: mkdocs (documentation build)
entry: uv run mkdocs build --strict
language: system
pass_filenames: false
files: ^(docs/|mkdocs\.yaml)
# ==========================================================================
# Commit Message Linting
# ==========================================================================
@@ -246,6 +259,33 @@ repos:
name: commitizen (commit format)
stages: [commit-msg]
# ==========================================================================
# AI Code Review (Local Only)
# ==========================================================================
# CodeRabbit CLI - AI-powered code review
# https://www.coderabbit.ai/cli
#
# SETUP REQUIRED:
# 1. Install: just coderabbit::install
# 2. Authenticate: just coderabbit::login
#
# USAGE:
# - Run manually: just review (or just coderabbit::review)
# - Run via pre-commit: uv run pre-commit run coderabbit --all-files
#
# NOTE: This hook uses 'manual' stage so it doesn't run automatically.
# The CodeRabbit GitHub App already reviews PRs, so CLI is for local use.
# Rate limits: Free=1/hour, Lite=1/hour, Pro=5/hour
- repo: local
hooks:
- id: coderabbit
name: coderabbit (AI code review)
entry: coderabbit review --plain --type uncommitted
language: system
pass_filenames: false
stages: [manual]
verbose: true
# ==========================================================================
# CI Configuration
# ==========================================================================
@@ -257,3 +297,4 @@ ci:
- hadolint-docker # Needs Docker
- trivyconfig-docker # Needs Docker
- trufflehog # Can be slow in CI
- coderabbit # GitHub App handles PR reviews; CLI is for local use
+5
View File
@@ -0,0 +1,5 @@
[project]
id = freecad-mcp-and-more
url = /codebases/freecad-mcp-and-more/findings
name = freecad-mcp-and-more
-1
View File
@@ -38,7 +38,6 @@
"Markdownlint",
"mcp",
"MCP",
"mdformat",
"mise",
"mypy",
"neka",
+3 -3
View File
@@ -1515,9 +1515,9 @@ FREECAD_XMLRPC_PORT = "9875"
**Setup:**
1. Run `just install-bridge-macro` to install the StartMCPBridge macro
1. Start FreeCAD and execute the macro: Macro → Macros → StartMCPBridge → Execute
1. The macro starts both XML-RPC (port 9875) and JSON-RPC (port 9876) servers
1. Install the MCP Bridge workbench via FreeCAD Addon Manager, or run `just run-gui` from source
1. Start the bridge using the workbench toolbar button or menu
1. The bridge starts both XML-RPC (port 9875) and JSON-RPC (port 9876) servers
### Mode 2: Local Embedded (Linux Only)
+96 -33
View File
@@ -22,11 +22,13 @@ Current requirement: **Python 3.11** (matching FreeCAD 1.0.x bundled Python)
This MCP server supports three connection modes. **Embedded mode does NOT work on macOS** due to how FreeCAD's libraries are linked.
| Mode | Description | Platform Support |
| ---------- | ------------------------------------------- | --------------------------------- |
| `xmlrpc` | Connects to FreeCAD via XML-RPC (port 9875) | **All platforms** (recommended) |
| `socket` | Connects via JSON-RPC socket (port 9876) | **All platforms** |
| `embedded` | Imports FreeCAD directly into process | **Linux only** (crashes on macOS) |
| Mode | Description | Platform Support | Testing Level |
| ---------- | ------------------------------------------- | --------------------------------- | ---------------- |
| `xmlrpc` | Connects to FreeCAD via XML-RPC (port 9875) | **All platforms** (recommended) | Full integration |
| `socket` | Connects via JSON-RPC socket (port 9876) | **All platforms** | Full integration |
| `embedded` | Imports FreeCAD directly into process | **Linux only** (crashes on macOS) | Unit tests only |
**Embedded mode testing:** Embedded mode is tested via mocked unit tests in CI. It does not have integration tests with actual FreeCAD because that would require running FreeCAD in-process on Linux CI runners. For production use, prefer `xmlrpc` or `socket` modes which have full integration test coverage.
**Why embedded mode fails on macOS:**
FreeCAD's `FreeCAD.so` library links to `@rpath/libpython3.11.dylib` (FreeCAD's bundled Python). When you try to import it from a different Python interpreter (even the same version), it causes a crash because the Python runtime state is incompatible.
@@ -35,13 +37,10 @@ FreeCAD's `FreeCAD.so` library links to `@rpath/libpython3.11.dylib` (FreeCAD's
1. Use `xmlrpc` or `socket` mode in your configuration
1. Start FreeCAD and run the MCP plugin inside FreeCAD's Python console:
1. Start FreeCAD and start the MCP bridge:
```python
from freecad_mcp.freecad_plugin.server import FreecadMCPPlugin
plugin = FreecadMCPPlugin()
plugin.start()
```
- Install the MCP Bridge workbench via Addon Manager, or
- Use `just run-gui` from the source repository
1. The MCP server will then connect to FreeCAD over the network
@@ -91,6 +90,58 @@ uv run freecad-mcp
- Running `pytest` directly will fail with "command not found"
- Always prefix with `uv run` when running Python tools directly
### Safety CLI Account (for Security Scanning)
This project uses [Safety CLI](https://safetycli.com/) for dependency vulnerability scanning. Safety requires a **free account** for the `safety scan` command.
**First-time setup:**
```bash
# Register for a free account (interactive)
uv run safety auth
# Or login if you already have an account
uv run safety auth --login
```
**Note:** The Safety CLI authentication is stored locally and only needs to be done once per machine. If you skip this step, `just check` will show a clear error message with instructions. The `safety` pre-commit hook will also fail with an authentication prompt.
**CI/CD:** Safety runs in CI using the `SAFETY_API_KEY` repository secret. The API key is passed via environment variable to the pre-commit hook.
### CodeRabbit CLI (for AI Code Reviews)
This project supports [CodeRabbit CLI](https://www.coderabbit.ai/cli) for AI-powered code reviews in your terminal. The CLI is optional for local development - the CodeRabbit GitHub App automatically reviews all PRs.
**First-time setup:**
```bash
# Install CodeRabbit CLI
just coderabbit::install
# Authenticate (opens browser)
just coderabbit::login
```
**Usage:**
```bash
# Review staged changes (most common)
just review
# Review with auto-fix suggestions
just coderabbit::review-fix
# Review changes since main branch
just coderabbit::review-branch
# See all available commands
just --list coderabbit
```
**Rate limits:** Free tier allows 1 review per hour. Pro tier allows 5 reviews per hour.
**CI/CD:** The CodeRabbit GitHub App handles PR reviews automatically. The CLI is skipped in CI since it's for local development workflow only.
### Workflow Commands (via `just`)
This project uses [`just`](https://just.systems/) as a command runner. Always prefer `just` commands over raw commands.
@@ -107,6 +158,7 @@ just --list quality
just --list testing
just --list freecad
just --list documentation
just --list coderabbit
# Common shortcut commands (aliases to module commands)
just install # Install project dependencies
@@ -136,6 +188,7 @@ just documentation::serve # Serve documentation locally
| `testing` | Test execution | `unit`, `cov`, `integration`, `all` |
| `freecad` | FreeCAD plugin and macro management | `run-gui`, `run-headless`, `install-*-macro` |
| `documentation` | Documentation building | `build`, `serve`, `open` |
| `coderabbit` | AI code reviews (local) | `install`, `login`, `review`, `review-fix` |
Module files are located in the `just/` directory.
@@ -206,12 +259,11 @@ just secrets-trufflehog # Verified secrets only
### Markdown Linting
All markdown files are linted and formatted for consistency:
All markdown files are linted for consistency:
```bash
just markdown-lint # Check markdown files
just markdown-fix # Auto-fix markdown issues
just markdown-format # Format with mdformat
just markdown-lint # Check markdown files
just markdown-fix # Auto-fix markdown issues
```
Configuration: `.markdownlint.yaml`
@@ -397,8 +449,12 @@ This catches issues early and ensures code quality standards are met. Never skip
### Version Policy
- **Always use the most recent stable releases** of all libraries and tools
- Pin exact versions in `pyproject.toml` for reproducibility
- Regularly update dependencies with `just update-deps`
- **Dependency specification follows Python best practices**:
- `pyproject.toml` uses `>=` minimum version constraints (e.g., `pydantic>=2.0`)
- `uv.lock` contains exact pinned versions for reproducible builds
- This allows the package to work as a library while ensuring reproducibility
- **Do not change `>=` to `==` in pyproject.toml** - this would break library usability
- Regularly update dependencies with `just update-deps` (updates uv.lock)
- Check for security vulnerabilities with `just security`
### Core Dependencies
@@ -464,22 +520,33 @@ When creating new files, always use the full extension.
## Justfile HEREDOC Syntax
**CRITICAL**: When writing heredocs in justfile recipes, the content must be indented to match the recipe body. Just parses non-indented lines as justfile syntax, which causes errors with Python code containing dots (e.g., `sys.path`).
**CRITICAL**: When writing heredocs in justfile recipes, the content must be indented to match the recipe body (4 spaces). Just parses non-indented lines as justfile syntax, which causes errors with Python code containing dots (e.g., `sys.path`).
**Important**: Just automatically strips leading indentation from heredoc content when executing. So while you write indented code in the justfile, the output will be properly unindented. Use `just --dry-run recipe-name` to verify the output.
### Correct Pattern
```just
# Recipe with heredoc - content MUST be indented
# Recipe with heredoc - content MUST be indented with 4 spaces
my-recipe:
#!/usr/bin/env bash
cat > "$FILE" << EOF
# Python code goes here - indented with spaces
# Python code goes here - indented with 4 spaces
import sys
if project_path not in sys.path:
sys.path.insert(0, project_path)
EOF
```
When executed, just strips the 4-space indent, producing valid Python:
```python
# Python code goes here - indented with 4 spaces
import sys
if project_path not in sys.path:
sys.path.insert(0, project_path)
```
### Incorrect Pattern (Will Fail)
```just
@@ -495,12 +562,13 @@ EOF
### Key Rules
1. **Indent heredoc content**: All lines inside the heredoc must be indented (4 spaces typically)
1. **Indent the EOF marker**: The closing `EOF` must also be indented to match
1. **Use `\\n` for newlines**: In heredoc strings that need literal `\n`, use `\\n`
1. **Variable expansion**: `${VAR}` works inside heredocs for bash variables
1. **Indent heredoc content with 4 spaces**: Match the recipe body indentation
2. **Indent the EOF marker**: The closing `EOF` must also be indented
3. **Do NOT double-indent**: 4 spaces is correct; 8 spaces would produce indented output
4. **Variable expansion**: `${VAR}` works inside heredocs for bash variables
5. **Use `\\n` for newlines**: In heredoc strings that need literal `\n`, use `\\n`
See the recipes in `just/freecad.just` (e.g., `install-bridge-macro`, `run-gui`, `run-headless`) for working examples.
See the recipes in `just/freecad.just` (e.g., `run-gui`, `run-headless`, `install-cut-macro`) for working examples.
---
@@ -696,13 +764,10 @@ just run-headless
**CRITICAL**: Code running inside FreeCAD's Python environment cannot import packages that aren't available in FreeCAD's bundled Python (like `mcp`, `pydantic`, etc.).
The `headless_server.py` script imports the plugin directly from the module file to avoid triggering the `mcp` import in `freecad_mcp/__init__.py`:
The `headless_server.py` script in the workbench addon imports the plugin directly from the module file to avoid triggering the `mcp` import:
```python
# WRONG - triggers mcp import via freecad_mcp/__init__.py
from freecad_mcp.freecad_plugin.server import FreecadMCPPlugin
# CORRECT - import directly from the module file
# CORRECT - import directly from the module file in the same directory
script_dir = str(Path(__file__).resolve().parent)
sys.path.insert(0, script_dir)
from server import FreecadMCPPlugin # Direct module import
@@ -710,9 +775,9 @@ from server import FreecadMCPPlugin # Direct module import
This pattern is required because:
1. `freecad_mcp/__init__.py` imports `from freecad_mcp.server import mcp`
1. The MCP SDK is installed in the project's virtualenv, not in FreeCAD's Python
1. Python processes parent package `__init__.py` files when importing nested modules
1. Importing from `freecad_mcp/__init__.py` would trigger MCP SDK imports that don't exist in FreeCAD
---
@@ -773,8 +838,6 @@ This project uses relaxed mypy settings because FastMCP lacks proper type stubs.
### Markdownlint (MD035)
- **Horizontal rules**: Must use `---` format (3 dashes)
- **mdformat-simple-breaks**: This plugin ensures mdformat produces `---` instead of 70 underscores
- **Conflict resolution**: markdownlint and mdformat must agree on horizontal rule style
### Bandit Security
+57 -43
View File
@@ -30,6 +30,11 @@ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that
- [MCP Client Configuration](#mcp-client-configuration)
- [Usage](#usage)
- [Starting the MCP Bridge in FreeCAD](#starting-the-mcp-bridge-in-freecad)
- [Option A: Using the Workbench (Recommended)](#option-a-using-the-workbench-recommended)
- [Option B: Using just commands (from source)](#option-b-using-just-commands-from-source)
- [Uninstalling the MCP Bridge](#uninstalling-the-mcp-bridge)
- [Checking for Legacy Components](#checking-for-legacy-components)
- [Manual Cleanup (if needed)](#manual-cleanup-if-needed)
- [Running Modes](#running-modes)
- [XML-RPC Mode (Recommended)](#xml-rpc-mode-recommended)
- [Socket Mode (JSON-RPC)](#socket-mode-json-rpc)
@@ -63,7 +68,6 @@ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that
- [Running Tests](#running-tests)
- [Code Quality](#code-quality)
- [Macro Development](#macro-development)
- [StartMCPBridge Macro](#startmcpbridge-macro)
- [CutObjectForMagnets Macro](#cutobjectformagnets-macro)
- [MultiExport Macro](#multiexport-macro)
- [Architecture](#architecture)
@@ -234,18 +238,19 @@ If using Docker:
Before your AI assistant can connect, you need to start the MCP bridge inside FreeCAD:
1. Install the bridge macro (one time):
##### Option A: Using the Workbench (Recommended)
```bash
# If installed from source:
just install-bridge-macro
1. Install the MCP Bridge workbench via FreeCAD's Addon Manager:
# Or manually copy from the macros/Start_MCP_Bridge/ directory
```
- **Edit -> Preferences -> Addon Manager**
- Search for "MCP Bridge"
- Install and restart FreeCAD
1. Start FreeCAD
1. Start the bridge:
1. Run the macro: **Macro -> Macros -> StartMCPBridge -> Execute**
- Switch to the MCP Bridge workbench
- Click the **Start MCP Bridge** button in the toolbar
- Or use the menu: **MCP Bridge -> Start Bridge**
1. You should see in the FreeCAD console:
@@ -255,7 +260,49 @@ Before your AI assistant can connect, you need to start the MCP bridge inside Fr
- Socket: localhost:9876
```
1. Start/restart your MCP client (Claude Code, etc.) - it will connect automatically
##### Option B: Using just commands (from source)
```bash
# Start FreeCAD with MCP bridge auto-started
just run-gui
# Or for headless/automation mode:
just run-headless
```
After starting the bridge, start/restart your MCP client (Claude Code, etc.) - it will connect automatically
#### Uninstalling the MCP Bridge
To uninstall the MCP Bridge workbench:
1. Open FreeCAD
1. Go to **Edit -> Preferences -> Addon Manager**
1. Find "MCP Bridge" in the list
1. Click **Uninstall**
1. Restart FreeCAD
##### Checking for Legacy Components
If you previously used older versions of this project, you may have legacy components installed. Run this command to check what's installed and get cleanup instructions:
```bash
just freecad::mcp-status
```
##### Manual Cleanup (if needed)
Remove any legacy files that may conflict with the workbench:
```bash
# macOS - remove legacy plugin and macro
rm -rf ~/Library/Application\ Support/FreeCAD/Mod/MCPBridge/
rm -f ~/Library/Application\ Support/FreeCAD/Macro/StartMCPBridge.FCMacro
# Linux - remove legacy plugin and macro
rm -rf ~/.local/share/FreeCAD/Mod/MCPBridge/
rm -f ~/.local/share/FreeCAD/Macro/StartMCPBridge.FCMacro
```
#### Running Modes
@@ -677,14 +724,8 @@ just docker::run # Run container
#### GUI Mode (recommended for development)
```bash
# Install the bridge macro
just install-bridge-macro
# Start FreeCAD with auto-started bridge
just run-gui
# Or start FreeCAD manually, then:
# Macro -> Macros -> StartMCPBridge -> Execute
```
#### Headless Mode (for automation/CI)
@@ -732,33 +773,6 @@ just secrets
## Macro Development
### StartMCPBridge Macro
The StartMCPBridge macro is required for the MCP server to communicate with FreeCAD. Developers working on the MCP integration should understand how it works.
**Location:** `macros/Start_MCP_Bridge/`
**What it does:**
1. Adds the freecad-mcp project source to Python's path
1. Imports and instantiates the `FreecadMCPPlugin`
1. Starts both XML-RPC (port 9875) and JSON-RPC (port 9876) servers
1. Registers handlers for executing Python code, managing documents, creating objects, etc.
**Installation for development:**
```bash
just install-bridge-macro
```
This copies the macro to FreeCAD's macro directory and injects the correct project path.
**Uninstall:**
```bash
just uninstall-bridge-macro
```
### CutObjectForMagnets Macro
**Location:** `macros/Cut_Object_for_Magnets/`
@@ -1,8 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<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 -->
@@ -15,7 +14,6 @@
<!-- 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 -->
@@ -29,22 +27,17 @@
<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>
<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 -->

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+12
View File
@@ -0,0 +1,12 @@
"""FreeCAD Robust MCP Workbench - Initialization.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
This module is executed when FreeCAD starts up. It handles non-GUI
initialization tasks for the MCP Bridge workbench.
"""
import FreeCAD
FreeCAD.Console.PrintMessage("FreeCAD Robust MCP: Init loaded\n")
+222
View File
@@ -0,0 +1,222 @@
"""FreeCAD Robust MCP Workbench - GUI Initialization.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
This module defines the workbench class and GUI commands for the
MCP Bridge. It provides toolbar buttons and menu items to start
and stop the MCP bridge server.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import FreeCAD
import FreeCADGui
# Global reference to the plugin instance
_mcp_plugin: Any = None
def get_addon_path() -> str:
"""Get the path to this addon's directory."""
return str(Path(__file__).resolve().parent)
def get_icon_path(icon_name: str) -> str:
"""Get the full path to an icon file.
Args:
icon_name: Name of the icon file (e.g., "FreecadRobustMCP.svg")
Returns:
Full path to the icon file.
"""
return str(Path(get_addon_path()) / icon_name)
class StartMCPBridgeCommand:
"""Command to start the MCP bridge server."""
def GetResources(self) -> dict[str, str]:
"""Return the command resources (icon, menu text, tooltip)."""
return {
"Pixmap": get_icon_path("FreecadRobustMCP.svg"),
"MenuText": "Start MCP Bridge",
"ToolTip": (
"Start the MCP bridge server for AI assistant integration.\n"
"Listens on XML-RPC (port 9875) and Socket (port 9876)."
),
}
def IsActive(self) -> bool:
"""Return True if the command can be executed."""
global _mcp_plugin # noqa: PLW0602
# Can only start if not already running
return _mcp_plugin is None or not _mcp_plugin.is_running
def Activated(self) -> None:
"""Execute the command to start the MCP bridge."""
global _mcp_plugin
if _mcp_plugin is not None and _mcp_plugin.is_running:
FreeCAD.Console.PrintWarning("MCP Bridge is already running.\n")
return
try:
# Import the server module from the bundled code
from freecad_mcp_bridge.server import FreecadMCPPlugin
# Create and start the plugin
_mcp_plugin = FreecadMCPPlugin(
host="localhost",
port=9876, # JSON-RPC socket port
xmlrpc_port=9875, # XML-RPC port
enable_xmlrpc=True,
)
_mcp_plugin.start()
FreeCAD.Console.PrintMessage("\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
FreeCAD.Console.PrintMessage("MCP Bridge started!\n")
FreeCAD.Console.PrintMessage(" - XML-RPC: localhost:9875\n")
FreeCAD.Console.PrintMessage(" - Socket: localhost:9876\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
FreeCAD.Console.PrintMessage(
"\nYou can now connect your MCP client (Claude Code, etc.) to FreeCAD.\n"
)
except ImportError as e:
FreeCAD.Console.PrintError(f"Failed to import MCP Bridge module: {e}\n")
FreeCAD.Console.PrintError(
"Ensure the FreecadRobustMCP addon is properly installed.\n"
)
except Exception as e:
FreeCAD.Console.PrintError(f"Failed to start MCP Bridge: {e}\n")
class StopMCPBridgeCommand:
"""Command to stop the MCP bridge server."""
def GetResources(self) -> dict[str, str]:
"""Return the command resources (icon, menu text, tooltip)."""
return {
"Pixmap": get_icon_path("FreecadRobustMCP.svg"),
"MenuText": "Stop MCP Bridge",
"ToolTip": "Stop the running MCP bridge server.",
}
def IsActive(self) -> bool:
"""Return True if the command can be executed."""
global _mcp_plugin # noqa: PLW0602
# Can only stop if currently running
return _mcp_plugin is not None and _mcp_plugin.is_running
def Activated(self) -> None:
"""Execute the command to stop the MCP bridge."""
global _mcp_plugin
if _mcp_plugin is None or not _mcp_plugin.is_running:
FreeCAD.Console.PrintWarning("MCP Bridge is not running.\n")
return
try:
_mcp_plugin.stop()
_mcp_plugin = None
FreeCAD.Console.PrintMessage("\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
FreeCAD.Console.PrintMessage("MCP Bridge stopped.\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
except Exception as e:
FreeCAD.Console.PrintError(f"Failed to stop MCP Bridge: {e}\n")
class MCPBridgeStatusCommand:
"""Command to show MCP bridge status."""
def GetResources(self) -> dict[str, str]:
"""Return the command resources (icon, menu text, tooltip)."""
return {
"Pixmap": get_icon_path("FreecadRobustMCP.svg"),
"MenuText": "MCP Bridge Status",
"ToolTip": "Show the current status of the MCP bridge server.",
}
def IsActive(self) -> bool:
"""Return True if the command can be executed."""
# Always active - can always show status
return True
def Activated(self) -> None:
"""Execute the command to show MCP bridge status."""
global _mcp_plugin # noqa: PLW0602
FreeCAD.Console.PrintMessage("\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
FreeCAD.Console.PrintMessage("MCP Bridge Status\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
if _mcp_plugin is None:
FreeCAD.Console.PrintMessage("Status: Not initialized\n")
elif not _mcp_plugin.is_running:
FreeCAD.Console.PrintMessage("Status: Stopped\n")
else:
FreeCAD.Console.PrintMessage("Status: Running\n")
FreeCAD.Console.PrintMessage(f" Instance ID: {_mcp_plugin.instance_id}\n")
FreeCAD.Console.PrintMessage(f" XML-RPC Port: {_mcp_plugin.xmlrpc_port}\n")
FreeCAD.Console.PrintMessage(f" Socket Port: {_mcp_plugin.socket_port}\n")
FreeCAD.Console.PrintMessage(
f" Requests processed: {_mcp_plugin.request_count}\n"
)
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
class FreecadRobustMCPWorkbench(FreeCADGui.Workbench):
"""FreeCAD Robust MCP Workbench.
Provides toolbar and menu commands to start, stop, and monitor
the MCP bridge server for AI assistant integration.
"""
MenuText = "MCP Bridge"
ToolTip = "MCP Bridge for AI assistant integration with FreeCAD"
Icon = get_icon_path("FreecadRobustMCP.svg")
def Initialize(self) -> None:
"""Initialize the workbench - called once when first activated."""
# Register commands
FreeCADGui.addCommand("Start_MCP_Bridge", StartMCPBridgeCommand())
FreeCADGui.addCommand("Stop_MCP_Bridge", StopMCPBridgeCommand())
FreeCADGui.addCommand("MCP_Bridge_Status", MCPBridgeStatusCommand())
# Create toolbar and menu
commands = ["Start_MCP_Bridge", "Stop_MCP_Bridge", "MCP_Bridge_Status"]
self.appendToolbar("MCP Bridge", commands)
self.appendMenu("MCP Bridge", commands)
FreeCAD.Console.PrintMessage("FreeCAD Robust MCP workbench initialized\n")
def Activated(self) -> None:
"""Called when the workbench is activated."""
pass
def Deactivated(self) -> None:
"""Called when the workbench is deactivated."""
pass
def ContextMenu(self, recipient: Any) -> None:
"""Called when right-clicking in the view or object tree."""
pass
def GetClassName(self) -> str:
"""Return the C++ class name for this workbench."""
return "Gui::PythonWorkbench"
# Register the workbench
FreeCADGui.addWorkbench(FreecadRobustMCPWorkbench())
@@ -0,0 +1,12 @@
"""FreeCAD MCP Bridge - Bundled server module for the workbench addon.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
This module provides the MCP bridge server that runs inside FreeCAD.
It is bundled with the workbench addon for self-contained installation.
"""
from .server import FreecadMCPPlugin
__all__ = ["FreecadMCPPlugin"]
@@ -1,16 +1,22 @@
#!/usr/bin/env python3
"""Headless FreeCAD MCP Bridge Server.
r"""Headless FreeCAD MCP Bridge Server.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
This script starts the MCP bridge server in FreeCAD's headless mode.
It should be run with FreeCADCmd (the headless FreeCAD executable).
Usage:
FreeCADCmd headless_server.py
# or
freecadcmd headless_server.py
# If workbench is installed via FreeCAD Addon Manager:
FreeCADCmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py
# On macOS:
/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd \
~/Library/Application\ Support/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py
Note: In headless mode, GUI features like screenshots are not available.
For full functionality, use the StartMCPBridge macro in FreeCAD's GUI.
For full functionality, use the workbench in FreeCAD's GUI.
"""
from __future__ import annotations
@@ -27,26 +33,26 @@ except ImportError:
print("ERROR: This script must be run with FreeCADCmd or inside FreeCAD.")
print("")
print("Usage:")
print(" just run-headless")
print(" # or")
print(" FreeCADCmd headless_server.py")
print(" freecadcmd headless_server.py")
print("")
print("On macOS:")
print(
" /Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd headless_server.py"
" FreeCADCmd /path/to/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py"
)
print("")
print("On Linux:")
print(" freecadcmd headless_server.py")
print("On macOS (if workbench installed):")
print(" /Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd \\")
print(
" ~/Library/Application\\ Support/FreeCAD/Mod/FreecadRobustMCP/"
"freecad_mcp_bridge/headless_server.py"
)
print("")
print("On Linux (if workbench installed):")
print(
" freecadcmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/"
"freecad_mcp_bridge/headless_server.py"
)
sys.exit(1)
# Import the plugin server directly from the module file
# We avoid importing through the package hierarchy (freecad_mcp.freecad_plugin.server)
# because freecad_mcp/__init__.py imports the MCP SDK which isn't available
# in FreeCAD's embedded Python environment
# Import the plugin server directly from the module file in the same directory
script_dir = str(Path(__file__).resolve().parent)
# Import directly from server.py in the same directory
sys.path.insert(0, script_dir)
from server import FreecadMCPPlugin # noqa: E402
@@ -54,7 +60,7 @@ from server import FreecadMCPPlugin # noqa: E402
plugin = FreecadMCPPlugin(
host="localhost",
port=9876, # JSON-RPC socket port
xmlrpc_port=9875, # XML-RPC port (neka-nat compatible)
xmlrpc_port=9875, # XML-RPC port
enable_xmlrpc=True,
)
@@ -127,6 +127,80 @@ class FreecadMCPPlugin:
self._request_count = 0
self._last_request_time: float | None = None
# =========================================================================
# Public API (for external access without using private attributes)
# =========================================================================
@property
def is_running(self) -> bool:
"""Check if the MCP bridge server is currently running.
Returns:
True if the server is running, False otherwise.
"""
return self._running
@property
def instance_id(self) -> str:
"""Get the unique instance ID for this server.
Returns:
UUID string identifying this server instance.
"""
return self._instance_id
@property
def socket_port(self) -> int:
"""Get the JSON-RPC socket server port.
Returns:
Port number for the socket server.
"""
return self._port
@property
def xmlrpc_port(self) -> int:
"""Get the XML-RPC server port.
Returns:
Port number for the XML-RPC server.
"""
return self._xmlrpc_port
@property
def request_count(self) -> int:
"""Get the total number of requests processed.
Returns:
Number of requests processed since server start.
"""
return self._request_count
def get_status(self) -> dict[str, Any]:
"""Get the current status of the MCP bridge server.
Returns:
Dictionary containing:
- running: Whether the server is running
- instance_id: Unique server instance ID
- socket_port: JSON-RPC socket port
- xmlrpc_port: XML-RPC port
- xmlrpc_enabled: Whether XML-RPC is enabled
- request_count: Total requests processed
- last_request_time: Timestamp of last request (or None)
- headless: Whether running in headless mode
"""
return {
"running": self._running,
"instance_id": self._instance_id,
"socket_port": self._port,
"xmlrpc_port": self._xmlrpc_port,
"xmlrpc_enabled": self._enable_xmlrpc,
"request_count": self._request_count,
"last_request_time": self._last_request_time,
"headless": self._headless,
}
def start(self) -> None:
"""Start all servers."""
if self._running:
+34
View File
@@ -0,0 +1,34 @@
# Bridge API Reference
The bridge module provides the communication layer between the MCP server and FreeCAD.
## Base Classes
::: freecad_mcp.bridge.base
options:
show_root_heading: true
show_source: true
## XML-RPC Bridge
::: freecad_mcp.bridge.xmlrpc
options:
show_root_heading: true
show_source: true
## Socket Bridge
::: freecad_mcp.bridge.socket
options:
show_root_heading: true
show_source: true
## Embedded Bridge
!!! warning "Linux Only"
The embedded bridge only works on Linux. See [Connection Modes](../guide/connection-modes.md) for details.
::: freecad_mcp.bridge.embedded
options:
show_root_heading: true
show_source: true
+6
View File
@@ -0,0 +1,6 @@
# Configuration API Reference
::: freecad_mcp.config
options:
show_root_heading: true
show_source: true
+11
View File
@@ -0,0 +1,11 @@
# Server API Reference
::: freecad_mcp.server
options:
show_root_heading: true
show_source: true
members:
\- mcp
\- get_bridge
\- startup
\- shutdown
+259
View File
@@ -0,0 +1,259 @@
# Architecture
This document provides a technical overview of the FreeCAD MCP Server architecture.
For the full architecture document with design decisions and rationale, see [ARCHITECTURE-MCP.md](https://github.com/spkane/freecad-robust-mcp-and-more/blob/main/ARCHITECTURE-MCP.md).
---
## Overview
The FreeCAD MCP Server follows a **Bridge with Adapter** pattern:
```text
┌─────────────────────────────────────────────────────────────────────────┐
│ MCP Server Layer │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ FastMCP Application │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Tools │ │ Resources│ │ Prompts │ │ Lifecycle│ │ │
│ │ │ (82+) │ │ │ │ │ │ Manager │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ FreeCAD Bridge Interface │ │
│ │ (Abstract Base Class) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ ╱ │ ╲ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ EmbeddedBridge │ │ SocketBridge │ │ XMLRPCBridge │ │
│ │ (Linux only) │ │ (JSON-RPC) │ │ (Recommended) │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## Module Structure
```text
src/freecad_mcp/
├── __init__.py
├── server.py # Main MCP server entry point
├── config.py # Configuration management
├── bridge/ # FreeCAD communication layer
│ ├── __init__.py # Bridge factory
│ ├── base.py # Abstract bridge interface
│ ├── embedded.py # In-process FreeCAD (Linux only)
│ ├── socket.py # JSON-RPC socket bridge
│ ├── xmlrpc.py # XML-RPC bridge (recommended)
│ └── protocol.py # Wire protocol definitions
├── tools/ # MCP tool implementations
│ ├── __init__.py
│ ├── execution.py # Python execution & debugging
│ ├── documents.py # Document management
│ ├── objects.py # Object creation/manipulation
│ ├── partdesign.py # PartDesign parametric modeling
│ ├── view.py # View, camera, display
│ ├── export.py # Export/import operations
│ └── macros.py # Macro management
├── resources/ # MCP resource implementations
│ ├── __init__.py
│ └── freecad.py # Document, console, capabilities
├── prompts/ # MCP prompt templates
│ ├── __init__.py
│ └── freecad.py # Modeling and debugging prompts
└── freecad_workbench_addon/ # Workbench addon (deprecated location)
```
---
## Bridge Architecture
### Base Interface
All bridges implement `FreecadBridge`:
```python
class FreecadBridge(ABC):
@abstractmethod
async def connect(self) -> None: ...
@abstractmethod
async def disconnect(self) -> None: ...
@abstractmethod
async def is_connected(self) -> bool: ...
@abstractmethod
async def execute_python(
self, code: str, timeout_ms: int = 30000
) -> ExecutionResult: ...
```
### XML-RPC Bridge (Recommended)
- Connects to FreeCAD via XML-RPC on port 9875
- Proven, reliable protocol
- Works on all platforms
### Socket Bridge
- Uses JSON-RPC over TCP sockets on port 9876
- Lower overhead than XML-RPC
- Easier to debug (JSON format)
### Embedded Bridge
- Imports FreeCAD directly into the MCP server process
- **Linux only** (crashes on macOS/Windows)
- Fastest execution (no IPC overhead)
- Headless mode only
---
## Workbench Addon Architecture
The workbench addon runs inside FreeCAD:
```text
addon/FreecadRobustMCP/
├── package.xml # FreeCAD addon metadata
├── Init.py # Module initialization
├── InitGui.py # GUI initialization (workbench)
├── FreecadRobustMCP.svg # Workbench icon
└── freecad_mcp_bridge/ # Bridge plugin
├── __init__.py
├── server.py # XML-RPC/JSON-RPC server
└── headless_server.py # Headless mode launcher
```
### Thread Safety
The workbench uses a queue-based system for thread-safe GUI operations:
```python
# Operations queued from network thread
request_queue.put(operation)
# Executed on main GUI thread via QTimer
def process_queue():
while not request_queue.empty():
op = request_queue.get()
result = op()
response_queue.put(result)
```
---
## Data Flow
### Tool Execution
```text
1. AI Assistant sends tool request
2. MCP Server receives request
3. Tool handler prepares Python code
4. Bridge.execute_python() sends code
5. FreeCAD executes code (main thread)
6. Result returned via bridge
7. MCP Server formats response
8. AI Assistant receives result
```
### Code Execution Pattern
Tools generate Python code that runs in FreeCAD:
```python
@mcp.tool()
async def create_box(length: float = 10.0, ...) -> dict:
bridge = await get_bridge()
code = f'''
doc = FreeCAD.ActiveDocument or FreeCAD.newDocument("Unnamed")
obj = doc.addObject("Part::Box", "Box")
obj.Length = {length}
doc.recompute()
_result_ = {{"name": obj.Name, "volume": obj.Shape.Volume}}
'''
result = await bridge.execute_python(code)
return result.result
```
---
## GUI Detection
Tools check `FreeCAD.GuiUp` to handle headless mode:
```python
code = f'''
if not FreeCAD.GuiUp:
_result_ = {{"success": False, "error": "GUI not available"}}
else:
# GUI-only operations
obj.ViewObject.Visibility = True
_result_ = {{"success": True}}
'''
```
---
## Configuration
Configuration via environment variables:
| Variable | Default | Description |
| --------------------- | ----------- | --------------------------- |
| `FREECAD_MODE` | `xmlrpc` | Connection mode |
| `FREECAD_PATH` | auto | FreeCAD lib path (embedded) |
| `FREECAD_SOCKET_HOST` | `localhost` | Socket/XML-RPC host |
| `FREECAD_SOCKET_PORT` | `9876` | JSON-RPC socket port |
| `FREECAD_XMLRPC_PORT` | `9875` | XML-RPC port |
| `FREECAD_TIMEOUT_MS` | `30000` | Execution timeout |
---
## Testing Strategy
### Unit Tests
- Mock FreeCAD module
- Test bridge logic in isolation
- Run on all platforms
### Integration Tests
- Use FreeCAD AppImage in CI
- Test actual FreeCAD operations
- Run in headless mode
### Embedded Mode Testing
Embedded mode receives **minimal testing**:
- Unit tests with mocked FreeCAD
- No CI integration tests (would require Linux + FreeCAD in-process)
- Recommended to use xmlrpc/socket modes for production
---
## Next Steps
- [Contributing](contributing.md) - How to contribute
- [Full Architecture Document](https://github.com/spkane/freecad-robust-mcp-and-more/blob/main/ARCHITECTURE-MCP.md) - Complete design details
+223
View File
@@ -0,0 +1,223 @@
# Contributing
Thank you for your interest in contributing to FreeCAD MCP Server!
---
## Development Setup
### Prerequisites
- Python 3.11 (must match FreeCAD's bundled version)
- [mise](https://mise.jdx.dev/) for tool management
- FreeCAD 0.21+ or 1.0+ installed
### Initial Setup
```bash
git clone https://github.com/spkane/freecad-robust-mcp-and-more.git
cd freecad-robust-mcp-and-more
# Install mise if not already installed
curl https://mise.run | sh
# Install project tools and dependencies
mise trust
mise install
just setup
```
### Safety CLI Account (Required for Security Scanning)
This project uses [Safety CLI](https://safetycli.com/) for dependency vulnerability scanning. Safety requires a **free account** for the `safety scan` command used in pre-commit hooks.
```bash
# Register for a free account (interactive)
uv run safety auth
# Or login if you already have an account
uv run safety auth --login
```
**Note:** Authentication is stored locally and only needs to be done once per machine. If you skip this step, the `safety` pre-commit hook will fail with an authentication prompt.
**CI/CD:** Safety runs in CI using the `SAFETY_API_KEY` repository secret.
### Running Tests
```bash
# Run all tests
just test
# Run unit tests only
just testing::unit
# Run with coverage
just testing::cov
# Run type checking
uv run mypy src/
```
### Code Quality
```bash
# Run all pre-commit checks
just check
# Run linting
just lint
# Format code
just format
# Run security checks
just quality::secrets
```
---
## Project Structure
```text
freecad-robust-mcp-and-more/
├── src/freecad_mcp/ # Main package
│ ├── bridge/ # FreeCAD connection bridges
│ ├── tools/ # MCP tool implementations
│ ├── resources/ # MCP resource implementations
│ ├── prompts/ # MCP prompt templates
│ └── server.py # Main server entry point
├── addon/ # FreeCAD workbench addon
│ └── FreecadRobustMCP/ # Workbench files
├── macros/ # Standalone FreeCAD macros
├── tests/ # Test suite
│ ├── unit/ # Unit tests
│ └── integration/ # Integration tests
├── docs/ # Documentation
└── just/ # Justfile modules
```
---
## Contribution Guidelines
### Code Style
- Follow PEP 8 with 88-character line length (ruff/black)
- Use type hints for all function signatures
- Write Google-style docstrings
- Run `just format` before committing
### Testing
- Write tests for all new functionality
- Maintain test coverage
- Run `just test` before submitting PRs
- Integration tests require FreeCAD (run via CI)
### Documentation
- Update docstrings for API changes
- Update user docs for feature changes
- Run `just docs` to build and verify
### Commits
- Use conventional commit format
- Keep commits focused and atomic
- Reference issues when applicable
---
## Adding New MCP Tools
1. **Choose the right module** in `src/freecad_mcp/tools/`
1. **Add the tool function** with proper docstring:
```python
@mcp.tool()
async def my_new_tool(
param1: str,
param2: int = 10,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Short description of what the tool does.
Args:
param1: Description of param1.
param2: Description of param2.
doc_name: Document name. Uses active if None.
Returns:
Dictionary with result information.
"""
bridge = await get_bridge()
code = f'''
# FreeCAD Python code here
_result_ = {{"success": True}}
'''
result = await bridge.execute_python(code)
return result.result or {"success": False}
```
1. **Add tests** in the appropriate test file
1. **Update documentation** in `docs/guide/tools.md`
1. **Update capabilities resource** in `src/freecad_mcp/resources/freecad.py`
---
## Adding New Connection Modes
1. Create a new bridge class in `src/freecad_mcp/bridge/`
1. Inherit from `FreecadBridge` base class
1. Implement all abstract methods
1. Add to bridge factory in `src/freecad_mcp/bridge/__init__.py`
1. Add configuration option in `src/freecad_mcp/config.py`
1. Update documentation
---
## Release Process
Releases are automated via GitHub Actions:
1. Update `CHANGELOG.md`
1. Create a GitHub Release with a version tag
1. CI builds and publishes:
- PyPI package
- Docker images
- Macro release archives
---
## Future Work
The following items are on the roadmap and welcome contributions:
### Embedded Mode Integration Tests
<!-- TODO: Add live FreeCAD integration tests for embedded mode -->
Currently, embedded mode has only mocked unit tests. Adding live integration tests would require:
1. CI workflow that runs on Linux (embedded mode is Linux-only)
1. Uses FreeCAD AppImage's bundled Python interpreter
1. Sets up `PYTHONPATH` and `LD_LIBRARY_PATH` to point to AppImage libs
1. Runs tests with `FREECAD_MODE=embedded`
**Challenge:** The AppImage bundles Python 3.11, so tests must run using that interpreter (not the system Python) to avoid ABI incompatibility.
**Reference:** See `macro-test.yaml` for how integration tests currently work with xmlrpc mode.
---
## Getting Help
- **Issues:** [GitHub Issues](https://github.com/spkane/freecad-robust-mcp-and-more/issues)
- **Discussions:** [GitHub Discussions](https://github.com/spkane/freecad-robust-mcp-and-more/discussions)
---
## License
This project is licensed under the MIT License. See [LICENSE](https://github.com/spkane/freecad-robust-mcp-and-more/blob/main/LICENSE) for details.
+162
View File
@@ -0,0 +1,162 @@
# Configuration
Configure the FreeCAD MCP Server using environment variables and MCP client settings.
---
## Environment Variables
| Variable | Description | Default |
| --------------------- | ---------------------------------------------------- | ----------- |
| `FREECAD_MODE` | Connection mode: `xmlrpc`, `socket`, or `embedded` | `xmlrpc` |
| `FREECAD_PATH` | Path to FreeCAD's lib directory (embedded mode only) | Auto-detect |
| `FREECAD_SOCKET_HOST` | Socket/XML-RPC server hostname | `localhost` |
| `FREECAD_SOCKET_PORT` | JSON-RPC socket server port | `9876` |
| `FREECAD_XMLRPC_PORT` | XML-RPC server port | `9875` |
| `FREECAD_TIMEOUT_MS` | Execution timeout in ms | `30000` |
---
## Connection Modes
The MCP server supports three connection modes:
| Mode | Description | Platform Support |
| ---------- | ------------------------------------------- | --------------------------------- |
| `xmlrpc` | Connects to FreeCAD via XML-RPC (port 9875) | **All platforms** (recommended) |
| `socket` | Connects via JSON-RPC socket (port 9876) | **All platforms** |
| `embedded` | Imports FreeCAD directly into process | **Linux only** (crashes on macOS) |
### XML-RPC Mode (Recommended)
The default and recommended mode. Works on all platforms.
```bash
export FREECAD_MODE=xmlrpc
freecad-mcp
```
### Socket Mode
Alternative to XML-RPC using JSON-RPC over TCP sockets.
```bash
export FREECAD_MODE=socket
freecad-mcp
```
### Embedded Mode (Linux Only)
!!! warning "Linux Only"
Embedded mode only works on Linux. On macOS and Windows, it will crash because FreeCAD's `FreeCAD.so` library links to its bundled Python, which conflicts with external Python interpreters.
Embedded mode imports FreeCAD directly into the MCP server process for fastest execution.
```bash
export FREECAD_MODE=embedded
export FREECAD_PATH=/usr/lib/freecad/lib
freecad-mcp
```
**Note:** Embedded mode testing is minimal. For production use, prefer `xmlrpc` or `socket` modes.
---
## MCP Client Configuration
### Claude Code / Claude Desktop
Add to `~/.claude/claude_desktop_config.json` or a project `.mcp.json` file:
```json
{
"mcpServers": {
"freecad": {
"command": "freecad-mcp",
"env": {
"FREECAD_MODE": "xmlrpc"
}
}
}
}
```
If installed from source with mise/uv:
```json
{
"mcpServers": {
"freecad": {
"command": "/path/to/mise/shims/uv",
"args": ["run", "--project", "/path/to/freecad-robust-mcp-and-more", "freecad-mcp"],
"env": {
"FREECAD_MODE": "xmlrpc"
}
}
}
}
```
### Docker Configuration
```json
{
"mcpServers": {
"freecad": {
"command": "docker",
"args": [
"run", "--rm", "-i",
"-e", "FREECAD_MODE=xmlrpc",
"-e", "FREECAD_SOCKET_HOST=host.docker.internal",
"spkane/freecad-robust-mcp"
]
}
}
}
```
---
## GUI vs Headless Mode
FreeCAD can run in two modes, and the MCP server works with both:
| Feature | Headless Mode | GUI Mode |
| ------------------------ | ------------- | -------- |
| Object creation | Yes | Yes |
| Boolean operations | Yes | Yes |
| Export (STEP, STL, etc.) | Yes | Yes |
| Save documents | Yes | Yes |
| Screenshots | No | Yes |
| Object colors | No | Yes |
| Object visibility | No | Yes |
| Camera control | No | Yes |
| Interactive selection | No | Yes |
### Starting FreeCAD
**GUI Mode** (for interactive work with visual feedback):
```bash
# Using just commands (from source)
just freecad::run-gui
# Or start FreeCAD normally and click "Start Bridge" in the workbench
```
**Headless Mode** (for automation, CI/CD, or when you don't need visual feedback):
```bash
# Using just commands (from source)
just freecad::run-headless
# Or run directly with FreeCADCmd
FreeCADCmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py
```
---
## Next Steps
- [Quick Start](quickstart.md) - Create your first model with AI assistance
- [Connection Modes](../guide/connection-modes.md) - Detailed guide on different connection modes
+126
View File
@@ -0,0 +1,126 @@
# Installation
This guide covers installing the FreeCAD MCP Server and connecting it to your AI assistant.
---
## Requirements
- **FreeCAD** 0.21+ or 1.0+ (with Python 3.11)
- **Python 3.11** (must match FreeCAD's bundled Python version)
- An **MCP-compatible AI assistant** (Claude Code, Cursor, etc.)
---
## Installation Methods
### Method 1: pip (Recommended)
The simplest way to install the MCP server:
```bash
pip install freecad-robust-mcp
```
### Method 2: From Source (for Development)
```bash
git clone https://github.com/spkane/freecad-robust-mcp-and-more.git
cd freecad-robust-mcp-and-more
# Install mise (if not already installed)
curl https://mise.run | sh
mise trust
mise install
just setup
```
### Method 3: Docker
Run the MCP server in a container:
```bash
# Pull from Docker Hub
docker pull spkane/freecad-robust-mcp
# Or build locally
docker build -t freecad-robust-mcp .
```
**Note:** The Docker container runs the MCP server only—it does not include FreeCAD itself. You must run FreeCAD with the MCP Bridge workbench on your host machine (or in a separate container) and configure the MCP server to connect via `xmlrpc` or `socket` mode.
**Why embedded mode doesn't work with Docker:** Embedded mode requires FreeCAD and the MCP server to run in the same process, which is impossible when FreeCAD runs on the host and the MCP server runs inside a Docker container. Additionally, embedded mode fails on macOS due to ABI incompatibility with FreeCAD's bundled Python libraries (`libpython3.11.dylib`). Always use `xmlrpc` or `socket` mode for Docker deployments.
---
## Installing the MCP Bridge Workbench
The MCP Bridge Workbench runs inside FreeCAD and provides the connection point for the MCP server.
### Via FreeCAD Addon Manager (Recommended)
1. Open FreeCAD
1. Go to **Tools > Addon Manager**
1. Search for "FreeCAD MCP and More" or "MCP Bridge"
1. Click **Install**
1. Restart FreeCAD
### Manual Installation
1. Download the latest release from [GitHub Releases](https://github.com/spkane/freecad-robust-mcp-and-more/releases)
1. Extract to your FreeCAD Mod directory:
- **Linux:** `~/.local/share/FreeCAD/Mod/`
- **macOS:** `~/Library/Application Support/FreeCAD/Mod/`
- **Windows:** `%APPDATA%\FreeCAD\Mod\`
1. Restart FreeCAD
---
## Verifying Installation
After installation, verify everything is working:
### Step 1: Start FreeCAD with the MCP Bridge
1. **Start FreeCAD** and select the **MCP Bridge** workbench from the workbench selector dropdown
1. **Click "Start MCP Bridge"** in the toolbar (or use the MCP Bridge menu)
1. Check the FreeCAD console for confirmation messages:
```text
MCP Bridge started!
- XML-RPC: localhost:9875
- Socket: localhost:9876
```
### Step 2: Verify the MCP Server
Test that the MCP server command is available:
```bash
# With pip installation
freecad-mcp --help
# With source installation
uv run freecad-mcp --help
```
### Step 3: Test the Connection
With FreeCAD running and the bridge started, you can verify connectivity:
```bash
# Quick connectivity test using curl (XML-RPC)
curl -X POST http://localhost:9875 \
-H "Content-Type: text/xml" \
-d '<?xml version="1.0"?><methodCall><methodName>ping</methodName></methodCall>'
```
A successful response indicates the bridge is working correctly.
---
## Next Steps
- [Configuration](configuration.md) - Set up environment variables and MCP client settings
- [Quick Start](quickstart.md) - Create your first model with AI assistance
+137
View File
@@ -0,0 +1,137 @@
# Quick Start
Get up and running with AI-assisted FreeCAD modeling in minutes.
---
## Prerequisites
Before starting, ensure you have:
1. FreeCAD installed with the MCP Bridge workbench
1. The MCP server installed (`pip install freecad-robust-mcp`)
1. Your MCP client configured (see [Configuration](configuration.md))
---
## Step 1: Start FreeCAD with the MCP Bridge
### Option A: GUI Mode (Recommended for getting started)
1. Open FreeCAD
1. Switch to the **MCP Bridge** workbench
1. Click **Start Bridge** in the toolbar
1. You should see: "MCP Bridge started! XML-RPC: localhost:9875, Socket: localhost:9876"
### Option B: Headless Mode (For automation)
```bash
# If installed via Addon Manager (Linux)
FreeCADCmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py
# If working from source
just freecad::run-headless
```
---
## Step 2: Connect Your AI Assistant
With FreeCAD and the MCP server configured, open your AI assistant (Claude Code, Cursor, etc.) and verify the connection:
```text
"Check the FreeCAD connection status"
```
The AI should respond with information about the connection mode, FreeCAD version, and whether GUI is available.
---
## Step 3: Create Your First Model
Try these example prompts with your AI assistant:
### Simple Box
```text
"Create a new FreeCAD document and add a box that is 20mm x 10mm x 5mm"
```
### Parametric Part with Fillet
```text
"Create a parametric bracket:
1. Start with a 50x30mm rectangular sketch
2. Extrude it 10mm
3. Add a 3mm fillet to all edges"
```
### Export for 3D Printing
```text
"Export the current model to STL format for 3D printing"
```
---
## Step 4: Explore Available Tools
The MCP server provides 82+ tools organized into categories:
| Category | Examples |
| ---------- | ---------------------------------------------------- |
| Primitives | `create_box`, `create_cylinder`, `create_sphere` |
| PartDesign | `create_sketch`, `pad_sketch`, `pocket_sketch` |
| Operations | `boolean_operation`, `fillet_edges`, `chamfer_edges` |
| Export | `export_stl`, `export_step`, `export_3mf` |
| View (GUI) | `get_screenshot`, `set_object_color` |
See the [Tools Reference](../guide/tools.md) for the complete list.
---
## Example Workflows
### Create a Mounting Bracket
```text
"Help me create a mounting bracket with:
- 60x40mm base plate, 5mm thick
- Two mounting holes (5mm diameter) at the corners
- A vertical wall 30mm tall on one edge
- 2mm fillets on all external edges"
```
### Modify an Existing Model
```text
"Open my_part.FCStd and:
1. List all the objects in the document
2. Change the height of the Pad feature from 10mm to 15mm
3. Save the document"
```
### Debug a Macro
```text
"Read the macro 'MyMacro' and explain what it does.
Then run it and show me any errors."
```
---
## Tips for Effective AI-Assisted Modeling
1. **Be specific about dimensions** - Include units (mm, cm, inches) in your requests
1. **Use parametric approaches** - Ask for PartDesign workflows instead of direct Part operations for parts you'll modify
1. **Check console output** - If something goes wrong, ask the AI to check the FreeCAD console for errors
1. **Take screenshots** - In GUI mode, ask for screenshots to verify the model looks correct
1. **Save frequently** - Ask the AI to save your document after significant changes
---
## Next Steps
- [Tools Reference](../guide/tools.md) - Complete API reference for all tools
- [User Guide](../USER_GUIDE.md) - Detailed workflows and best practices
- [Connection Modes](../guide/connection-modes.md) - Understanding connection modes
+202
View File
@@ -0,0 +1,202 @@
# Connection Modes
The FreeCAD MCP Server supports multiple ways to connect to FreeCAD. Choose the mode that best fits your workflow.
---
## Overview
| Mode | Description | Platform | Best For |
| ---------- | --------------------------------- | ------------- | -------------------------------- |
| `xmlrpc` | XML-RPC protocol (port 9875) | All platforms | Production use (recommended) |
| `socket` | JSON-RPC over TCP sockets | All platforms | Alternative to XML-RPC |
| `embedded` | FreeCAD imported into MCP process | Linux only | Fastest execution, CI/automation |
---
## XML-RPC Mode (Recommended)
XML-RPC mode is the **default and recommended** connection method. It works on all platforms and provides robust, reliable communication.
### How It Works
```text
MCP Client <--stdio--> MCP Server <--XML-RPC:9875--> FreeCAD
```
The MCP server communicates with FreeCAD via XML-RPC protocol on port 9875.
### Setup
1. Start FreeCAD with the MCP Bridge workbench
1. Click **Start Bridge** (or it auto-starts if configured)
1. Configure the MCP server:
```bash
export FREECAD_MODE=xmlrpc
export FREECAD_XMLRPC_PORT=9875 # default
freecad-mcp
```
### Advantages
- Works on all platforms (macOS, Linux, Windows)
- Process isolation (FreeCAD crash doesn't affect MCP server)
- Supports both GUI and headless FreeCAD
---
## Socket Mode
Socket mode uses JSON-RPC over TCP sockets instead of XML-RPC.
### How It Works
```text
MCP Client <--stdio--> MCP Server <--JSON-RPC:9876--> FreeCAD
```
### Setup
```bash
export FREECAD_MODE=socket
export FREECAD_SOCKET_HOST=localhost
export FREECAD_SOCKET_PORT=9876
freecad-mcp
```
### Advantages
- JSON-based protocol (easier to debug)
- Lower overhead than XML-RPC
- Works on all platforms
---
## Embedded Mode (Linux Only)
!!! danger "Platform Limitation"
Embedded mode **only works on Linux**. On macOS and Windows, it causes crashes due to Python ABI incompatibility.
Embedded mode imports FreeCAD directly into the MCP server process, providing the fastest execution.
### Why It Crashes on macOS/Windows
FreeCAD's `FreeCAD.so` library links to `@rpath/libpython3.11.dylib` (FreeCAD's bundled Python). When you try to import it from a different Python interpreter (even the same version), it causes a crash because the Python runtime state is incompatible.
### Setup (Linux Only)
```bash
export FREECAD_MODE=embedded
export FREECAD_PATH=/usr/lib/freecad/lib # Adjust for your system
freecad-mcp
```
### Advantages
- Fastest execution (no IPC overhead)
- No need to start FreeCAD separately
- Works in CI/CD environments on Linux
### Limitations
- **Linux only** - crashes on macOS and Windows
- Headless only (no GUI features)
- **Minimal testing** - embedded mode receives less testing than xmlrpc/socket modes
- Cannot access FreeCAD GUI features (screenshots, colors, etc.)
### Testing Status
Embedded mode is tested in the CI pipeline with unit tests that mock FreeCAD. However, full integration testing with actual FreeCAD is limited compared to the xmlrpc and socket modes which are tested with the FreeCAD AppImage.
---
## Choosing a Mode
```mermaid
graph TD
A[Need FreeCAD MCP?] --> B{Platform?}
B -->|macOS/Windows| C[Use xmlrpc or socket]
B -->|Linux| D{Need GUI features?}
D -->|Yes| C
D -->|No| E{Need fastest execution?}
E -->|Yes| F[Consider embedded]
E -->|No| C
```
### Recommendations
| Use Case | Recommended Mode |
| ----------------------------- | ---------------- |
| General development | `xmlrpc` |
| Interactive modeling with GUI | `xmlrpc` |
| CI/CD pipelines on Linux | `embedded` |
| Docker containers | `xmlrpc` |
| Remote FreeCAD instance | `xmlrpc` |
| Debugging connection issues | `socket` |
---
## Headless vs GUI Mode
Independent of connection mode, FreeCAD itself can run in GUI or headless mode:
| Feature | Headless | GUI |
| ------------------------ | -------- | --- |
| Object creation | Yes | Yes |
| Boolean operations | Yes | Yes |
| Export (STEP, STL, etc.) | Yes | Yes |
| Screenshots | No | Yes |
| Object colors/visibility | No | Yes |
| Camera control | No | Yes |
### Starting FreeCAD
**GUI Mode:**
```bash
# Using workbench - just start FreeCAD and click "Start Bridge"
just freecad::run-gui # From source
```
**Headless Mode:**
```bash
FreeCADCmd /path/to/headless_server.py
just freecad::run-headless # From source
```
---
## Troubleshooting
### Connection Refused
```text
Error: Connection refused on localhost:9875
```
**Solution:** Ensure FreeCAD is running with the MCP Bridge started. Check the bridge status in FreeCAD's toolbar.
### Embedded Mode Crash on macOS
```text
SIGSEGV: Segmentation fault
```
**Solution:** Embedded mode doesn't work on macOS. Switch to `xmlrpc` or `socket` mode.
### Timeout Errors
```text
Error: Execution timed out after 30000ms
```
**Solution:** Increase the timeout with `FREECAD_TIMEOUT_MS=60000` or optimize your operation.
---
## Next Steps
- [Tools Reference](tools.md) - Complete API for all 82+ tools
- [MCP Resources](resources.md) - Query FreeCAD state via MCP resources
+218
View File
@@ -0,0 +1,218 @@
# FreeCAD Macros
This project includes standalone FreeCAD macros that work independently of the MCP server, plus MCP tools for creating and managing macros programmatically.
---
## Included Macros
### CutObjectForMagnets
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.
**Features:**
- Interactive plane selection via GUI
- Automatic magnet hole placement with configurable grid
- Surface collision detection to avoid invalid hole positions
- Configurable magnet dimensions and tolerances
**Usage:**
1. Select an object in FreeCAD
1. Run the macro
1. Define the cutting plane interactively
1. Configure magnet parameters
1. The macro creates two halves with aligned magnet holes
See [CutObjectForMagnets documentation](https://github.com/spkane/freecad-robust-mcp-and-more/tree/main/macros/Cut_Object_for_Magnets) for detailed usage.
### MultiExport
Export selected bodies to multiple file formats simultaneously with configurable mesh options.
**Supported Formats:**
- STL (ASCII and Binary)
- STEP
- 3MF
- OBJ
- IGES
- BREP
- PLY
- AMF
**Usage:**
1. Select one or more bodies/parts
1. Run the macro
1. Select output formats and configure mesh options
1. Choose output directory
1. All exports are created with consistent naming
See [MultiExport documentation](https://github.com/spkane/freecad-robust-mcp-and-more/tree/main/macros/Multi_Export) for detailed usage.
---
## Installing Macros
### Via FreeCAD Addon Manager
When you install the "FreeCAD MCP and More" addon, the macros are installed automatically.
### Manual Installation
1. Download macros from [GitHub Releases](https://github.com/spkane/freecad-robust-mcp-and-more/releases)
1. Copy `.FCMacro` files to your macro directory:
- **Linux:** `~/.local/share/FreeCAD/Macro/`
- **macOS:** `~/Library/Application Support/FreeCAD/Macro/`
- **Windows:** `%APPDATA%\FreeCAD\Macro\`
---
## MCP Macro Tools
The MCP server provides tools for working with macros programmatically:
### list_macros
List available macros in FreeCAD's macro directories.
```python
list_macros() -> list[dict]
```
**Returns:** List of macros with name, path, description, and whether it's a system macro.
### run_macro
Execute a macro by name with optional arguments.
```python
run_macro(
macro_name: str,
args: dict | None = None
) -> dict
```
**Example prompt:**
```text
"Run the MultiExport macro"
```
### create_macro
Create a new macro programmatically.
```python
create_macro(
name: str,
code: str,
description: str = ""
) -> dict
```
**Example prompt:**
```text
"Create a macro called 'CreateBox' that makes a 10x10x10 box"
```
### read_macro
Read the source code of an existing macro.
```python
read_macro(macro_name: str) -> dict
```
**Example prompt:**
```text
"Show me the code for the MultiExport macro"
```
### delete_macro
Delete a user macro (system macros are protected).
```python
delete_macro(macro_name: str) -> dict
```
### create_macro_from_template
Create a macro from predefined templates.
```python
create_macro_from_template(
name: str,
template: str = "basic",
description: str = ""
) -> dict
```
**Available templates:**
| Template | Description |
| ----------- | --------------------------- |
| `basic` | Minimal macro with imports |
| `part` | Part workbench operations |
| `sketch` | Sketcher operations |
| `gui` | GUI/dialog template |
| `selection` | Selection handling template |
**Example prompt:**
```text
"Create a new macro from the 'sketch' template called 'DrawGear'"
```
---
## Macro Development with AI
The MCP server excels at helping develop FreeCAD macros. Example workflows:
### Debugging an Existing Macro
```text
"Read the macro 'MyMacro' and explain what it does"
```
```text
"Run the macro and show me any errors from the FreeCAD console"
```
### Creating a New Macro
```text
"Create a macro that:
1. Gets all selected objects
2. Calculates their combined bounding box
3. Creates a box around them with 5mm clearance"
```
### Modifying a Macro
```text
"Read the 'ExportSTL' macro and modify it to also export STEP files"
```
---
## Best Practices for Macro Development
1. **Use templates** - Start from `create_macro_from_template` for proper imports
1. **Test incrementally** - Use `execute_python` for testing snippets before creating full macros
1. **Check console output** - Use `get_console_output` to debug issues
1. **Document your macros** - Add docstrings that explain parameters and usage
1. **Handle errors gracefully** - Wrap operations in try/except blocks
---
## Next Steps
- [Tools Reference](tools.md) - Complete API for all MCP tools
- [Workbench](workbench.md) - MCP Bridge Workbench details
+335
View File
@@ -0,0 +1,335 @@
# MCP Resources
The FreeCAD MCP server exposes several resources that allow AI assistants to query FreeCAD's state without executing code.
---
## Overview
MCP Resources are read-only endpoints that provide context about FreeCAD's current state. They're useful for:
- Understanding what documents and objects exist
- Getting system information
- Discovering available capabilities
---
## Available Resources
The MCP server provides 12 resources for querying FreeCAD state:
### freecad://capabilities
Returns a comprehensive JSON catalog of all available tools, resources, and prompts.
**Use case:** Understanding what the MCP server can do.
**Example response:**
```json
{
"tools": {
"execution": ["execute_python", "get_freecad_version", ...],
"documents": ["create_document", "open_document", ...],
...
},
"resources": ["freecad://capabilities", "freecad://documents", ...],
"prompts": ["freecad-help", "create-parametric-part", ...]
}
```
### freecad://version
Gets FreeCAD version and build information.
**Use case:** Checking FreeCAD compatibility and environment.
**Example response:**
```json
{
"version": "1.0.0",
"build_date": "2024-01-15",
"python_version": "3.11.6",
"gui_available": true
}
```
### freecad://status
Gets current FreeCAD connection and runtime status.
**Use case:** Verifying connection health and mode.
**Example response:**
```json
{
"connected": true,
"mode": "xmlrpc",
"freecad_version": "1.0.0",
"gui_available": true,
"last_ping_ms": 12.5,
"error": null
}
```
### freecad://documents
Lists all open FreeCAD documents with basic information.
**Use case:** Seeing what documents are currently open.
**Example response:**
```json
[
{
"name": "MyPart",
"label": "My Part Design",
"path": "/home/user/projects/mypart.FCStd",
"is_modified": true,
"object_count": 15,
"active_object": "Pad"
}
]
```
### freecad://documents/{name}
Gets detailed information about a specific document.
**Use case:** Examining a document's contents.
**Example response:**
```json
{
"name": "MyPart",
"label": "My Part Design",
"path": "/home/user/projects/mypart.FCStd",
"objects": ["Body", "Sketch", "Pad", "Fillet"],
"is_modified": true,
"active_object": "Fillet"
}
```
### freecad://documents/{name}/objects
Gets list of objects in a specific document.
**Use case:** Listing all objects in a document with their types.
**Example response:**
```json
[
{
"name": "Body",
"label": "Body",
"type_id": "PartDesign::Body",
"visibility": true
},
{
"name": "Sketch",
"label": "Sketch",
"type_id": "Sketcher::SketchObject",
"visibility": false
}
]
```
### freecad://objects/{doc_name}/{obj_name}
Gets detailed information about a specific object including properties and shape data.
**Use case:** Inspecting object properties and geometry.
**Example response:**
```json
{
"name": "Pad",
"label": "Pad",
"type_id": "PartDesign::Pad",
"properties": {
"Length": 10.0,
"Type": "Length",
"Symmetric": false
},
"shape_info": {
"shape_type": "Solid",
"volume": 1000.0,
"area": 600.0,
"is_valid": true
},
"children": [],
"parents": ["Sketch"],
"visibility": true
}
```
### freecad://active-document
Gets the currently active document.
**Use case:** Quick access to the document the user is working on.
**Example response:**
```json
{
"name": "MyPart",
"label": "My Part Design",
"path": "/home/user/projects/mypart.FCStd",
"objects": ["Body", "Sketch", "Pad"],
"is_modified": false,
"active_object": "Pad"
}
```
### freecad://workbenches
Gets list of available FreeCAD workbenches.
**Use case:** Understanding what workbenches are available.
**Example response:**
```json
[
{
"name": "PartDesignWorkbench",
"label": "Part Design",
"is_active": true
},
{
"name": "SketcherWorkbench",
"label": "Sketcher",
"is_active": false
}
]
```
### freecad://workbenches/active
Gets the currently active workbench.
**Use case:** Knowing which workbench context is active.
**Example response:**
```json
{
"name": "PartDesignWorkbench",
"label": "Part Design"
}
```
### freecad://macros
Gets list of available FreeCAD macros.
**Use case:** Discovering available automation macros.
**Example response:**
```json
[
{
"name": "MultiExport",
"path": "/home/user/.local/share/FreeCAD/Macro/MultiExport.FCMacro",
"description": "Export objects to multiple formats",
"is_system": false
}
]
```
### freecad://console
Gets recent FreeCAD console output.
**Use case:** Debugging and seeing FreeCAD messages.
**Example response:**
```json
{
"lines": [
"MCP Bridge started!",
" - XML-RPC: localhost:9875",
" - Socket: localhost:9876",
"Document created: MyPart"
],
"count": 4
}
```
---
## Using Resources in Prompts
When talking to an AI assistant connected via MCP, resources are automatically available. The AI can read them to understand context.
**Example conversation:**
```text
User: "What documents do I have open?"
AI: [Reads freecad://documents resource]
"You have two documents open:
1. 'Bracket' - modified, 12 objects
2. 'Housing' - saved, 8 objects"
```
---
## Resources vs Tools
| Aspect | Resources | Tools |
| ------------ | ----------------------- | -------------------- |
| Purpose | Query state (read-only) | Perform actions |
| Side effects | None | May modify documents |
| Response | Data/text | Operation result |
| Example | `freecad://documents` | `create_document()` |
---
## Resource URI Summary
| URI | Description |
| ----------------------------------------- | ---------------------------------------- |
| `freecad://capabilities` | All available tools, resources, prompts |
| `freecad://version` | FreeCAD version and build info |
| `freecad://status` | Connection status and mode |
| `freecad://documents` | List of open documents |
| `freecad://documents/{name}` | Single document details |
| `freecad://documents/{name}/objects` | Objects in a document |
| `freecad://objects/{doc_name}/{obj_name}` | Detailed object information |
| `freecad://active-document` | Currently active document |
| `freecad://workbenches` | Available workbenches |
| `freecad://workbenches/active` | Currently active workbench |
| `freecad://macros` | Available macros |
| `freecad://console` | Recent console output |
---
## Implementing Custom Resources
If you're extending the MCP server, you can add custom resources:
```python
@mcp.resource("freecad://custom/{param}")
async def my_custom_resource(param: str) -> str:
"""Return custom data based on param."""
# Query FreeCAD and return data
result = await bridge.execute_python(f"...")
return json.dumps(result)
```
---
## Next Steps
- [Tools Reference](tools.md) - Complete API for MCP tools
- [Connection Modes](connection-modes.md) - How to connect to FreeCAD
+219
View File
@@ -0,0 +1,219 @@
# Tools Reference
The FreeCAD MCP server provides 82+ tools for CAD operations. This page provides a quick reference organized by category.
For detailed documentation including parameters and examples, see [MCP Tools Reference](../MCP_TOOLS_REFERENCE.md).
---
## Tool Categories
| Category | Tools | Description |
| ------------------------------- | ----- | ------------------------------------ |
| [Execution](#execution-tools) | 5 | Python execution, debugging |
| [Documents](#document-tools) | 7 | Document management |
| [Primitives](#primitive-tools) | 8 | Basic 3D shapes |
| [Objects](#object-tools) | 12 | Object manipulation |
| [PartDesign](#partdesign-tools) | 19 | Parametric modeling |
| [View & Display](#view-tools) | 11 | View control, screenshots (GUI only) |
| [Export/Import](#export-tools) | 7 | File format conversion |
| [Macros](#macro-tools) | 6 | Macro management |
| [Utility](#utility-tools) | 7 | Undo/redo, parts library |
---
## Execution Tools
| Tool | Description |
| ---------------------------- | ----------------------------------- |
| `execute_python` | Execute arbitrary Python in FreeCAD |
| `get_freecad_version` | Get FreeCAD version and build info |
| `get_connection_status` | Check MCP bridge connection |
| `get_console_output` | Get recent console output |
| `get_mcp_server_environment` | Get MCP server environment info |
---
## Document Tools
| Tool | Description |
| --------------------- | ----------------------------- |
| `list_documents` | List all open documents |
| `get_active_document` | Get currently active document |
| `create_document` | Create a new document |
| `open_document` | Open an existing .FCStd file |
| `save_document` | Save a document |
| `close_document` | Close a document |
| `recompute_document` | Recompute all features |
---
## Primitive Tools
| Tool | Description |
| ----------------- | ---------------------------- |
| `create_box` | Create a parametric box |
| `create_cylinder` | Create a parametric cylinder |
| `create_sphere` | Create a parametric sphere |
| `create_cone` | Create a parametric cone |
| `create_torus` | Create a torus (donut) |
| `create_wedge` | Create a tapered wedge |
| `create_helix` | Create a helix curve |
| `create_object` | Create any object by type ID |
---
## Object Tools
| Tool | Description |
| ------------------- | -------------------------------- |
| `list_objects` | List objects in a document |
| `inspect_object` | Get detailed object information |
| `edit_object` | Modify object properties |
| `delete_object` | Delete an object |
| `boolean_operation` | Union, cut, or intersect objects |
| `set_placement` | Set position and rotation |
| `rotate_object` | Rotate around an axis |
| `scale_object` | Scale uniformly or non-uniformly |
| `copy_object` | Create a copy |
| `mirror_object` | Mirror across a plane |
| `get_selection` | Get selected objects (GUI) |
| `set_selection` | Select objects (GUI) |
| `clear_selection` | Clear selection (GUI) |
---
## PartDesign Tools
### Bodies and Sketches
| Tool | Description |
| ------------------------ | ------------------------------- |
| `create_partdesign_body` | Create a PartDesign body |
| `create_sketch` | Create a sketch on a plane/face |
### Sketch Geometry
| Tool | Description |
| ---------------------- | ----------------------- |
| `add_sketch_rectangle` | Add rectangle to sketch |
| `add_sketch_circle` | Add circle to sketch |
| `add_sketch_line` | Add line to sketch |
| `add_sketch_arc` | Add arc to sketch |
| `add_sketch_point` | Add point to sketch |
### Additive Features
| Tool | Description |
| ------------------- | ------------------------------ |
| `pad_sketch` | Extrude sketch (additive) |
| `revolution_sketch` | Revolve sketch around axis |
| `loft_sketches` | Loft through multiple sketches |
| `sweep_sketch` | Sweep profile along path |
### Subtractive Features
| Tool | Description |
| --------------- | ----------------------- |
| `pocket_sketch` | Cut by extruding sketch |
| `groove_sketch` | Cut by revolving sketch |
| `create_hole` | Create parametric holes |
### Edge Operations & Patterns
| Tool | Description |
| ------------------ | --------------------------- |
| `fillet_edges` | Add rounded edges |
| `chamfer_edges` | Add beveled edges |
| `linear_pattern` | Repeat feature linearly |
| `polar_pattern` | Repeat feature circularly |
| `mirrored_feature` | Mirror feature across plane |
---
## View Tools
!!! warning "GUI Mode Required"
Tools marked with **GUI** only work when FreeCAD is running in GUI mode.
| Tool | Mode | Description |
| ----------------------- | ---- | ---------------------------------- |
| `get_screenshot` | GUI | Capture 3D view screenshot |
| `set_view_angle` | Both | Set camera angle |
| `fit_all` | Both | Fit all objects in view |
| `zoom_in` | GUI | Zoom in |
| `zoom_out` | GUI | Zoom out |
| `set_camera_position` | GUI | Set exact camera position |
| `set_object_visibility` | GUI | Show/hide objects |
| `set_display_mode` | GUI | Set display mode (wireframe, etc.) |
| `set_object_color` | GUI | Change object color |
| `list_workbenches` | Both | List available workbenches |
| `activate_workbench` | Both | Switch workbench |
---
## Export Tools
| Tool | Description |
| ------------- | ---------------------------------- |
| `export_step` | Export to STEP format |
| `export_stl` | Export to STL (3D printing) |
| `export_3mf` | Export to 3MF (modern 3D printing) |
| `export_obj` | Export to OBJ format |
| `export_iges` | Export to IGES format |
| `import_step` | Import STEP files |
| `import_stl` | Import STL files |
---
## Macro Tools
| Tool | Description |
| ---------------------------- | ------------------------------- |
| `list_macros` | List available macros |
| `run_macro` | Execute a macro |
| `create_macro` | Create a new macro |
| `read_macro` | Read macro source code |
| `delete_macro` | Delete a user macro |
| `create_macro_from_template` | Create from predefined template |
---
## Utility Tools
| Tool | Description |
| -------------------------- | --------------------------- |
| `undo` | Undo last operation |
| `redo` | Redo undone operation |
| `get_undo_redo_status` | Get undo/redo availability |
| `recompute` | Force recompute all objects |
| `get_console_log` | Get console log with levels |
| `list_parts_library` | List parts library |
| `insert_part_from_library` | Insert part from library |
---
## GUI vs Headless Mode
When running in headless mode, GUI-only tools return structured errors instead of crashing:
```json
{
"success": false,
"error": "GUI not available - screenshots cannot be captured in headless mode"
}
```
To check the current mode programmatically:
```python
result = await execute_python("_result_ = FreeCAD.GuiUp")
is_gui_mode = result["result"]
```
---
## Next Steps
- [MCP Tools Reference](../MCP_TOOLS_REFERENCE.md) - Detailed documentation with parameters and examples
- [MCP Resources](resources.md) - Query FreeCAD state via MCP resources
+220
View File
@@ -0,0 +1,220 @@
# MCP Bridge Workbench
The MCP Bridge Workbench is a FreeCAD addon that provides the server-side connection point for the MCP server. It runs inside FreeCAD and exposes XML-RPC and JSON-RPC interfaces.
---
## Overview
The workbench provides:
- **Toolbar controls** for starting/stopping the MCP bridge
- **Status indicator** showing connection state
- **XML-RPC server** on port 9875 (default)
- **JSON-RPC socket server** on port 9876 (default)
- **Headless mode support** for automation and CI/CD
---
## Installation
### Via FreeCAD Addon Manager (Recommended)
1. Open FreeCAD
1. Go to **Tools > Addon Manager**
1. Search for "FreeCAD MCP and More" or "MCP Bridge"
1. Click **Install**
1. Restart FreeCAD
### Manual Installation
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\`
---
## GUI Mode Usage
### Starting the Bridge
1. Switch to the **MCP Bridge** workbench in FreeCAD
1. Click **Start Bridge** in the toolbar
1. The status indicator turns green when running
You'll see a confirmation message:
```text
MCP Bridge started!
- XML-RPC: localhost:9875
- Socket: localhost:9876
```
### Stopping the Bridge
Click **Stop Bridge** in the toolbar. The status indicator turns red.
### Status Indicator
| Color | Status |
| ------ | ------------------------------------- |
| Green | Bridge running, accepting connections |
| Red | Bridge stopped |
| Yellow | Bridge starting/stopping |
---
## Headless Mode Usage
The workbench includes a headless server script for running without the FreeCAD GUI.
### Starting Headless Mode
**Linux:**
```bash
freecadcmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py
```
**macOS:**
```bash
/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd \
~/Library/Application\ Support/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py
```
**Using just commands (from source):**
```bash
just freecad::run-headless
```
### Headless Output
```text
FreeCAD version: 1.0.0
============================================================
MCP Bridge started in headless mode!
- XML-RPC: localhost:9875
- Socket: localhost:9876
Note: Screenshot and view features are not available in headless mode.
Press Ctrl+C to stop.
============================================================
```
---
## Features Available by Mode
| Feature | GUI Mode | Headless Mode |
| ------------------------ | -------- | ------------- |
| Object creation | Yes | Yes |
| Boolean operations | Yes | Yes |
| Export (STEP, STL, etc.) | Yes | Yes |
| Macro execution | Yes | Yes |
| Document management | Yes | Yes |
| Screenshots | Yes | **No** |
| Object colors | Yes | **No** |
| Object visibility | Yes | **No** |
| Camera/view control | Yes | **No** |
| Interactive selection | Yes | **No** |
!!! info "GUI-Only Features"
When a GUI-only feature is requested in headless mode, the MCP server returns a structured error response instead of crashing: `{"success": false, "error": "GUI not available - screenshots cannot be captured in headless mode"}`
---
## Configuration
The workbench uses default ports that can be customized in the MCP server configuration:
| Server | Default Port | Environment Variable |
| ------- | ------------ | --------------------- |
| XML-RPC | 9875 | `FREECAD_XMLRPC_PORT` |
| Socket | 9876 | `FREECAD_SOCKET_PORT` |
---
## Architecture
```text
┌─────────────────────────────────────────────────────────────┐
│ FreeCAD (GUI or Headless) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ MCP Bridge Workbench/Plugin │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ XML-RPC Server │ │ Socket Server │ │ │
│ │ │ (port 9875) │ │ (port 9876) │ │ │
│ │ └────────┬────────┘ └────────┬────────┘ │ │
│ │ │ │ │ │
│ │ └────────┬───────────┘ │ │
│ │ │ │ │
│ │ ┌────────▼────────┐ │ │
│ │ │ FreecadMCPPlugin│ │ │
│ │ │ (Thread-safe │ │ │
│ │ │ queue system) │ │ │
│ │ └────────┬────────┘ │ │
│ │ │ │ │
│ │ ┌────────▼────────┐ │ │
│ │ │ FreeCAD Python │ │ │
│ │ │ Console │ │ │
│ │ └─────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ Network (localhost)
┌─────────────────────────────────────────────────────────────┐
│ FreeCAD MCP Server (External Process) │
└─────────────────────────────────────────────────────────────┘
```
The workbench uses a **queue-based thread safety system** to ensure FreeCAD operations run on the main GUI thread, preventing crashes from thread-unsafe operations.
---
## Troubleshooting
### Bridge Won't Start
**Problem:** Clicking "Start Bridge" does nothing or shows an error.
**Solution:**
1. Check the FreeCAD Python console for error messages
1. Ensure no other process is using ports 9875/9876
1. Try restarting FreeCAD
### Connection Refused from MCP Server
**Problem:** MCP server reports "Connection refused"
**Solution:**
1. Verify the bridge is running (green status indicator)
1. Check that ports match between workbench and MCP server config
1. If using Docker, ensure you're using `host.docker.internal` as the host
### Headless Mode Hangs
**Problem:** `FreeCADCmd` with headless server never outputs anything
**Solution:**
1. Ensure you're using `FreeCADCmd` (not `freecad`)
1. Check the script path is correct
1. Try running with `-c "print('test')"` first to verify FreeCAD works
---
## Included Macros
The addon bundle also includes standalone FreeCAD macros:
- **MultiExport** - Export objects to multiple formats simultaneously
- **CutObjectForMagnets** - Cut objects with aligned magnet holes for 3D printing
See [Macros](macros.md) for details.
+61 -32
View File
@@ -4,47 +4,35 @@ Welcome to the FreeCAD MCP Server documentation.
This project provides an [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that enables integration between AI assistants (Claude, GPT, and other MCP-compatible tools) and [FreeCAD](https://www.freecadweb.org/), allowing AI-assisted development and debugging of 3D models, macros, and workbenches.
## Documentation
---
| Document | Description |
| --------------------------------------------- | ------------------------------------------------------ |
| [README](../README.md) | Project overview, installation, and configuration |
| [User Guide](USER_GUIDE.md) | How to use AI assistants with FreeCAD for CAD modeling |
| [MCP Tools Reference](MCP_TOOLS_REFERENCE.md) | Complete API reference for all 82+ MCP tools |
| [Architecture](../ARCHITECTURE-MCP.md) | Technical design and module structure |
| [Comparison](COMPARISON.md) | Analysis of other FreeCAD MCP implementations |
| [CLAUDE.md](../CLAUDE.md) | AI assistant guidelines for this project |
## Features
- **82+ MCP Tools** - Comprehensive CAD operations including primitives, PartDesign, booleans, export
- **Multiple Connection Modes** - XML-RPC (recommended), JSON-RPC socket, or embedded (Linux only)
- **GUI & Headless Support** - Full modeling in headless mode, plus screenshots/colors in GUI mode
- **Macro Development** - Create, edit, run, and template FreeCAD macros via MCP
- **Standalone Macros** - Useful FreeCAD macros that work independently of the MCP server
---
## Quick Start
```bash
# Clone and setup
git clone https://github.com/spkane/freecad-robust-mcp-and-more.git
cd freecad-robust-mcp-and-more
# Install the MCP server
pip install freecad-robust-mcp
# Install mise via the Official mise installer script (if not already installed)
curl https://mise.run | sh
# Install the workbench via FreeCAD Addon Manager
# (search for "FreeCAD MCP and More")
mise install
just setup
# Start FreeCAD and click "Start Bridge" in the MCP Bridge workbench
# Start FreeCAD with MCP bridge
just install-bridge-macro
just run-gui
# Run the MCP server (in another terminal or via your MCP client)
FREECAD_MODE=xmlrpc freecad-mcp
# Configure your MCP client and start building!
```
See the [README](../README.md) for detailed installation and configuration instructions.
See [Installation](getting-started/installation.md) for detailed setup instructions.
## Features
- **82+ MCP Tools**: Comprehensive CAD operations including primitives, PartDesign, booleans, export
- **Multiple Connection Modes**: XML-RPC (recommended), JSON-RPC socket, or embedded
- **GUI & Headless Support**: Full modeling in headless mode, plus screenshots/colors in GUI mode
- **Macro Development**: Create, edit, run, and template FreeCAD macros
- **PartDesign Workflow**: Parametric modeling with sketches, pads, pockets, fillets, patterns
---
## Connection Modes
@@ -54,9 +42,50 @@ See the [README](../README.md) for detailed installation and configuration instr
| `socket` | JSON-RPC socket (port 9876) | All platforms |
| `embedded` | In-process FreeCAD | Linux only |
See [Connection Modes](guide/connection-modes.md) for details on choosing the right mode.
---
## GUI vs Headless Mode
The MCP server works with FreeCAD in both GUI and headless mode:
| Feature | Headless | GUI |
| ------------------------ | -------- | --- |
| Object creation | Yes | Yes |
| Boolean operations | Yes | Yes |
| Export (STEP, STL, etc.) | Yes | Yes |
| Screenshots | No | Yes |
| Object colors/visibility | No | Yes |
| Camera control | No | Yes |
---
## FreeCAD Macros
This project includes standalone FreeCAD macros:
- **[StartMCPBridge](../macros/Start_MCP_Bridge/)** - Starts the MCP bridge server for AI assistant integration
- **[CutObjectForMagnets](../macros/Cut_Object_for_Magnets/)** - Cuts objects along planes with automatic magnet hole placement
- **[CutObjectForMagnets](guide/macros.md#cutobjectformagnets)** - Cuts objects along planes with automatic magnet hole placement
- **[MultiExport](guide/macros.md#multiexport)** - Export objects to multiple formats simultaneously
---
## Documentation
| Section | Description |
| -------------------------------------------------- | ------------------------------------------------- |
| [Getting Started](getting-started/installation.md) | Installation, configuration, and quick start |
| [User Guide](guide/connection-modes.md) | Connection modes, workbench, macros, and tools |
| [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 |
---
## 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)
+211
View File
@@ -0,0 +1,211 @@
# CodeRabbit CLI commands
# Usage: just coderabbit::install, just coderabbit::review, etc.
#
# CodeRabbit CLI provides AI-powered code reviews in your terminal.
# https://www.coderabbit.ai/cli
#
# IMPORTANT - RATE LIMITS:
# - Free tier: 1 review per hour
# - Pro tier: 5 reviews per hour
# Run reviews manually when needed to avoid hitting limits.
#
# Note: In CI, skip CodeRabbit CLI since the GitHub App handles PR reviews.
# These commands are for local, on-demand development workflow only.
# =============================================================================
# Installation
# =============================================================================
# Install CodeRabbit CLI (prefers Homebrew on macOS/Linux, falls back to official installer)
#
# SECURITY NOTE:
# - Homebrew (preferred): Uses Homebrew's cask verification and signed binaries
# from https://formulae.brew.sh/cask/coderabbit
# - Fallback installer: Downloads from https://cli.coderabbit.ai/install.sh
# The installer script is from CodeRabbit's official domain and installs
# signed binaries. We download to a temp file first for inspection if needed.
#
# Trust decision: CodeRabbit is a well-known code review service with a public
# GitHub organization (https://github.com/coderabbitai). The CLI is optional
# and only used for local development reviews.
install:
#!/usr/bin/env bash
set -euo pipefail
echo "Installing CodeRabbit CLI..."
# Prefer Homebrew if available (macOS and Linux)
if command -v brew &>/dev/null; then
echo "Using Homebrew to install CodeRabbit CLI..."
brew install --cask coderabbit
echo ""
echo "CodeRabbit CLI installed via Homebrew."
echo "Run 'just coderabbit::login' to authenticate."
exit 0
fi
# Fallback: Download installer to temp file first (allows inspection)
echo "Homebrew not found. Using official installer..."
INSTALLER_URL="https://cli.coderabbit.ai/install.sh"
TEMP_SCRIPT=$(mktemp /tmp/coderabbit-install.XXXXXX.sh)
echo "Downloading installer to: $TEMP_SCRIPT"
curl -fsSL "$INSTALLER_URL" -o "$TEMP_SCRIPT"
echo "Installer downloaded. You can inspect it at: $TEMP_SCRIPT"
echo "Executing installer..."
sh "$TEMP_SCRIPT"
# Clean up
rm -f "$TEMP_SCRIPT"
echo ""
echo "CodeRabbit CLI installed. Run 'just coderabbit::login' to authenticate."
# Check if CodeRabbit CLI is installed
check-installed:
@command -v coderabbit >/dev/null 2>&1 || { echo "CodeRabbit CLI not installed. Run: just coderabbit::install"; exit 1; }
@coderabbit --version
# =============================================================================
# Authentication
# =============================================================================
# Login to CodeRabbit (opens browser for authentication)
login: check-installed
coderabbit auth login
# Logout from CodeRabbit
logout: check-installed
coderabbit auth logout
# Check authentication status
auth-status: check-installed
coderabbit auth status
# =============================================================================
# Code Reviews
# =============================================================================
# Review staged changes (preserves user's staging state)
review: check-installed
#!/usr/bin/env bash
set -euo pipefail
echo "Reviewing staged changes..."
# Check if there are staged changes
if ! git diff --cached --quiet; then
# Stash unstaged changes, keeping staged changes in working tree
# This allows coderabbit to review only what's staged
STASH_OUTPUT=$(git stash push --keep-index -m "coderabbit-review-temp" 2>&1) || true
# Run the review on staged changes
coderabbit review --plain --type uncommitted || true
# Restore unstaged changes if we stashed anything
if [[ "$STASH_OUTPUT" != "No local changes to save" ]]; then
git stash pop --quiet || true
fi
else
echo "No staged changes to review. Stage changes with 'git add' first."
echo "Or use 'just coderabbit::review-all' to review all uncommitted changes."
fi
# Review staged changes with auto-fix suggestions (preserves user's staging state)
review-fix: check-installed
#!/usr/bin/env bash
set -euo pipefail
echo "Reviewing staged changes with auto-fix..."
# Check if there are staged changes
if ! git diff --cached --quiet; then
# Stash unstaged changes, keeping staged changes in working tree
STASH_OUTPUT=$(git stash push --keep-index -m "coderabbit-review-temp" 2>&1) || true
# Run the review with auto-fix on staged changes
coderabbit review --plain --type uncommitted --auto-fix || true
# Restore unstaged changes if we stashed anything
if [[ "$STASH_OUTPUT" != "No local changes to save" ]]; then
git stash pop --quiet || true
fi
else
echo "No staged changes to review. Stage changes with 'git add' first."
fi
# Review ALL uncommitted changes (staged + unstaged)
review-all: check-installed
@echo "Reviewing all uncommitted changes..."
coderabbit review --plain --type uncommitted
# Review the last commit
review-last: check-installed
coderabbit review --plain --type committed --base-commit HEAD~1
# Review changes since a specific commit (usage: just coderabbit::review-since abc123)
review-since commit: check-installed
coderabbit review --plain --type committed --base-commit {{commit}}
# Review changes between current branch and main
review-branch: check-installed
coderabbit review --plain --type committed --base-commit main
# =============================================================================
# Output Formats
# =============================================================================
# Generate prompt-only output for staged changes (for AI agents like Claude Code)
prompt-only: check-installed
#!/usr/bin/env bash
set -euo pipefail
# Check if there are staged changes
if ! git diff --cached --quiet; then
# Stash unstaged changes, keeping staged changes in working tree
STASH_OUTPUT=$(git stash push --keep-index -m "coderabbit-prompt-temp" 2>&1) || true
# Generate prompt for staged changes
coderabbit review --prompt-only --type uncommitted || true
# Restore unstaged changes if we stashed anything
if [[ "$STASH_OUTPUT" != "No local changes to save" ]]; then
git stash pop --quiet || true
fi
else
echo "No staged changes. Stage changes with 'git add' first."
fi
# Review staged changes with JSON output
review-json: check-installed
#!/usr/bin/env bash
set -euo pipefail
# Check if there are staged changes
if ! git diff --cached --quiet; then
# Stash unstaged changes, keeping staged changes in working tree
STASH_OUTPUT=$(git stash push --keep-index -m "coderabbit-json-temp" 2>&1) || true
# Run review with JSON output
coderabbit review --format json --type uncommitted || true
# Restore unstaged changes if we stashed anything
if [[ "$STASH_OUTPUT" != "No local changes to save" ]]; then
git stash pop --quiet || true
fi
else
echo "No staged changes. Stage changes with 'git add' first."
fi
# =============================================================================
# Configuration
# =============================================================================
# Show CodeRabbit configuration
config-show: check-installed
coderabbit config show
# Show help for all CodeRabbit commands
help: check-installed
coderabbit --help
+5 -1
View File
@@ -1,10 +1,14 @@
# Documentation commands
# Usage: just docs::build, just docs::serve, etc.
# Usage: just documentation::build, just documentation::serve, etc.
# Build documentation
build:
uv run mkdocs build
# Build documentation with strict mode (fails on warnings, for CI)
build-strict:
uv run mkdocs build --strict
# Serve documentation locally
serve:
uv run mkdocs serve
+189 -95
View File
@@ -1,11 +1,11 @@
# FreeCAD plugin and macro commands
# Usage: just freecad::run-gui, just freecad::install-bridge-macro, etc.
# FreeCAD workbench and macro commands
# Usage: just freecad::run-gui, just freecad::install-workbench, etc.
# Project root directory (justfile_directory() returns the main justfile's directory)
project_root := justfile_directory()
# =============================================================================
# Running FreeCAD
# Running FreeCAD with MCP Bridge
# =============================================================================
# Run MCP bridge server in FreeCAD headless mode
@@ -13,7 +13,8 @@ run-headless:
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="{{project_root}}"
SCRIPT_PATH="${PROJECT_DIR}/src/freecad_mcp/freecad_plugin/headless_server.py"
# Use the headless server from the addon directory (source of truth)
SCRIPT_PATH="${PROJECT_DIR}/addon/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py"
# Find FreeCADCmd executable based on OS
FREECAD_CMD=""
@@ -64,9 +65,6 @@ run-headless:
echo "Using FreeCAD: $FREECAD_CMD"
echo ""
# Add project src to PYTHONPATH so FreeCAD can find the module
export PYTHONPATH="${PROJECT_DIR}/src:${PYTHONPATH:-}"
# Run FreeCADCmd with the headless server script
"$FREECAD_CMD" "$SCRIPT_PATH"
@@ -75,7 +73,7 @@ run-headless-custom freecad_cmd:
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="{{project_root}}"
SCRIPT_PATH="${PROJECT_DIR}/src/freecad_mcp/freecad_plugin/headless_server.py"
SCRIPT_PATH="${PROJECT_DIR}/addon/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py"
if [[ ! -x "{{freecad_cmd}}" ]]; then
echo "ERROR: FreeCADCmd not found or not executable: {{freecad_cmd}}"
@@ -85,28 +83,60 @@ run-headless-custom freecad_cmd:
echo "Using FreeCAD: {{freecad_cmd}}"
echo ""
# Add project src to PYTHONPATH so FreeCAD can find the module
export PYTHONPATH="${PROJECT_DIR}/src:${PYTHONPATH:-}"
# Run FreeCADCmd with the headless server script
"{{freecad_cmd}}" "$SCRIPT_PATH"
# Run FreeCAD GUI with MCP bridge plugin auto-started
# Run FreeCAD GUI with MCP bridge (requires workbench to be installed)
run-gui:
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="{{project_root}}"
# Use the gui_startup.py script from the project
STARTUP_SCRIPT="${PROJECT_DIR}/src/freecad_mcp/freecad_plugin/gui_startup.py"
# Create a temporary startup script that starts the MCP bridge
STARTUP_SCRIPT=$(mktemp /tmp/freecad_mcp_startup.XXXXXX.py)
if [[ ! -f "$STARTUP_SCRIPT" ]]; then
echo "ERROR: Startup script not found: $STARTUP_SCRIPT"
exit 1
fi
# Use the server from the installed workbench location
# Note: For development, use run-gui-custom which passes the addon path explicitly
cat > "$STARTUP_SCRIPT" << 'PYTHON_EOF'
# FreeCAD MCP Bridge Auto-Start Script
import sys
import os
from pathlib import Path
# Set environment variable for the project path
export FREECAD_MCP_PROJECT_PATH="${PROJECT_DIR}/src"
# Determine installed workbench location based on platform
if sys.platform == "darwin":
addon_path = Path.home() / "Library" / "Application Support" / "FreeCAD" / "Mod" / "FreecadRobustMCP" / "freecad_mcp_bridge"
elif sys.platform == "win32":
addon_path = Path(os.environ.get("APPDATA", "")) / "FreeCAD" / "Mod" / "FreecadRobustMCP" / "freecad_mcp_bridge"
else:
addon_path = Path.home() / ".local" / "share" / "FreeCAD" / "Mod" / "FreecadRobustMCP" / "freecad_mcp_bridge"
if not addon_path.exists():
import FreeCAD
FreeCAD.Console.PrintError("MCP Bridge workbench not found.\n")
FreeCAD.Console.PrintError(f"Expected at: {addon_path}\n")
FreeCAD.Console.PrintError("Install the workbench first: just freecad::install-workbench\n")
else:
try:
addon_path_str = str(addon_path)
if addon_path_str not in sys.path:
sys.path.insert(0, addon_path_str)
from server import FreecadMCPPlugin
plugin = FreecadMCPPlugin(
host="localhost",
port=9876,
xmlrpc_port=9875,
enable_xmlrpc=True,
)
plugin.start()
import FreeCAD
FreeCAD.Console.PrintMessage("\nMCP Bridge started!\n")
FreeCAD.Console.PrintMessage(" - XML-RPC: localhost:9875\n")
FreeCAD.Console.PrintMessage(" - Socket: localhost:9876\n\n")
except Exception as e:
import FreeCAD
FreeCAD.Console.PrintError(f"Failed to start MCP Bridge: {e}\n")
PYTHON_EOF
# Find FreeCAD GUI executable based on OS
FREECAD_GUI=""
@@ -158,9 +188,6 @@ run-gui:
echo "Using FreeCAD: $FREECAD_GUI"
echo ""
# Add project src to PYTHONPATH so FreeCAD can find the module
export PYTHONPATH="${PROJECT_DIR}/src:${PYTHONPATH:-}"
# Launch FreeCAD with the startup script
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS: Use 'open' with --args to pass the script
@@ -184,15 +211,18 @@ run-gui-custom freecad_path:
# Create a temporary startup script
STARTUP_SCRIPT=$(mktemp /tmp/freecad_mcp_startup.XXXXXX.py)
# Use the server from the addon directory
ADDON_PATH="${PROJECT_DIR}/addon/FreecadRobustMCP/freecad_mcp_bridge"
cat > "$STARTUP_SCRIPT" << EOF
# FreeCAD MCP Bridge Auto-Start Script
import sys
project_path = "${PROJECT_DIR}/src"
if project_path not in sys.path:
sys.path.insert(0, project_path)
addon_path = "${ADDON_PATH}"
if addon_path not in sys.path:
sys.path.insert(0, addon_path)
try:
from freecad_mcp.freecad_plugin.server import FreecadMCPPlugin
from server import FreecadMCPPlugin
plugin = FreecadMCPPlugin(
host="localhost",
port=9876,
@@ -200,10 +230,12 @@ run-gui-custom freecad_path:
enable_xmlrpc=True,
)
plugin.start()
import FreeCAD
FreeCAD.Console.PrintMessage("\\nMCP Bridge started!\\n")
FreeCAD.Console.PrintMessage(" - XML-RPC: localhost:9875\\n")
FreeCAD.Console.PrintMessage(" - Socket: localhost:9876\\n\\n")
except Exception as e:
import FreeCAD
FreeCAD.Console.PrintError(f"Failed to start MCP Bridge: {e}\\n")
EOF
@@ -211,8 +243,6 @@ run-gui-custom freecad_path:
echo "Using FreeCAD: {{freecad_path}}"
echo ""
export PYTHONPATH="${PROJECT_DIR}/src:${PYTHONPATH:-}"
if [[ "$OSTYPE" == "darwin"* ]] && [[ "{{freecad_path}}" == *.app ]]; then
open -a "{{freecad_path}}" --args "$STARTUP_SCRIPT"
else
@@ -222,52 +252,9 @@ run-gui-custom freecad_path:
echo "FreeCAD is starting with MCP bridge..."
# =============================================================================
# Macro Installation
# Macro Installation (MultiExport, CutObjectForMagnets)
# =============================================================================
# Install the StartMCPBridge macro to FreeCAD's macro directory
install-bridge-macro:
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="{{project_root}}"
# Determine macro directory based on OS
if [[ "$OSTYPE" == "darwin"* ]]; then
MACRO_DIR="$HOME/Library/Application Support/FreeCAD/Macro"
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
MACRO_DIR="$HOME/.local/share/FreeCAD/Macro"
else
MACRO_DIR="$APPDATA/FreeCAD/Macro"
fi
mkdir -p "$MACRO_DIR"
# Copy the macro file and replace the placeholder with the actual project path
sed "s|__PROJECT_PATH__|${PROJECT_DIR}/src|g" \
"${PROJECT_DIR}/macros/Start_MCP_Bridge/StartMCPBridge.FCMacro" \
> "$MACRO_DIR/StartMCPBridge.FCMacro"
echo "StartMCPBridge macro installed to: $MACRO_DIR"
echo ""
echo "To use:"
echo " 1. Start FreeCAD"
echo " 2. Go to: Macro -> Macros -> StartMCPBridge -> Execute"
echo " 3. Restart your MCP client (Claude Code, etc.) to connect"
# Uninstall the StartMCPBridge macro
uninstall-bridge-macro:
#!/usr/bin/env bash
set -euo pipefail
if [[ "$OSTYPE" == "darwin"* ]]; then
MACRO_DIR="$HOME/Library/Application Support/FreeCAD/Macro"
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
MACRO_DIR="$HOME/.local/share/FreeCAD/Macro"
else
MACRO_DIR="$APPDATA/FreeCAD/Macro"
fi
rm -f "$MACRO_DIR/StartMCPBridge.FCMacro"
echo "StartMCPBridge macro uninstalled"
# Install the CutObjectForMagnets macro to FreeCAD's macro directory
install-cut-macro:
#!/usr/bin/env bash
@@ -371,42 +358,149 @@ uninstall-export-macro:
echo "MultiExport macro uninstalled"
# Install all macros to FreeCAD's macro directory
install-all-macros: install-bridge-macro install-cut-macro install-export-macro
install-all-macros: install-cut-macro install-export-macro
@echo "All macros installed successfully!"
# Uninstall all macros from FreeCAD's macro directory
uninstall-all-macros: uninstall-bridge-macro uninstall-cut-macro uninstall-export-macro
uninstall-all-macros: uninstall-cut-macro uninstall-export-macro
@echo "All macros uninstalled successfully!"
# =============================================================================
# Plugin Installation
# Workbench Addon Installation
# =============================================================================
# Install the FreeCAD plugin to user's FreeCAD directory
install-plugin:
# Install the FreeCAD Robust MCP workbench addon to FreeCAD's Mod directory
install-workbench:
#!/usr/bin/env bash
set -euo pipefail
if [[ "$OSTYPE" == "darwin"* ]]; then
PLUGIN_DIR="$HOME/Library/Application Support/FreeCAD/Mod/MCPBridge"
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
PLUGIN_DIR="$HOME/.local/share/FreeCAD/Mod/MCPBridge"
else
PLUGIN_DIR="$APPDATA/FreeCAD/Mod/MCPBridge"
fi
mkdir -p "$PLUGIN_DIR"
cp -r src/freecad_mcp/freecad_plugin/* "$PLUGIN_DIR/"
echo "Plugin installed to: $PLUGIN_DIR"
PROJECT_DIR="{{project_root}}"
ADDON_NAME="FreecadRobustMCP"
# Uninstall the FreeCAD plugin
uninstall-plugin:
# Determine FreeCAD Mod directory based on OS
if [[ "$OSTYPE" == "darwin"* ]]; then
MOD_DIR="$HOME/Library/Application Support/FreeCAD/Mod"
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
MOD_DIR="$HOME/.local/share/FreeCAD/Mod"
else
MOD_DIR="$APPDATA/FreeCAD/Mod"
fi
ADDON_DEST="$MOD_DIR/$ADDON_NAME"
# Create Mod directory if it doesn't exist
mkdir -p "$MOD_DIR"
# Remove existing installation if present
if [[ -d "$ADDON_DEST" ]]; then
echo "Removing existing installation at: $ADDON_DEST"
rm -rf "$ADDON_DEST"
fi
# Copy the addon directory
cp -r "${PROJECT_DIR}/addon/$ADDON_NAME" "$ADDON_DEST"
echo ""
echo "=========================================="
echo "FreeCAD Robust MCP Workbench installed!"
echo "=========================================="
echo ""
echo "Installation path: $ADDON_DEST"
echo ""
echo "To use:"
echo " 1. Start FreeCAD"
echo " 2. Select the 'MCP Bridge' workbench from the workbench selector"
echo " 3. Click 'Start MCP Bridge' in the toolbar"
echo " 4. Connect your MCP client (Claude Code, etc.) to FreeCAD"
echo ""
# Uninstall the FreeCAD Robust MCP workbench addon
uninstall-workbench:
#!/usr/bin/env bash
set -euo pipefail
ADDON_NAME="FreecadRobustMCP"
if [[ "$OSTYPE" == "darwin"* ]]; then
PLUGIN_DIR="$HOME/Library/Application Support/FreeCAD/Mod/MCPBridge"
MOD_DIR="$HOME/Library/Application Support/FreeCAD/Mod"
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
PLUGIN_DIR="$HOME/.local/share/FreeCAD/Mod/MCPBridge"
MOD_DIR="$HOME/.local/share/FreeCAD/Mod"
else
PLUGIN_DIR="$APPDATA/FreeCAD/Mod/MCPBridge"
MOD_DIR="$APPDATA/FreeCAD/Mod"
fi
rm -rf "$PLUGIN_DIR"
echo "Plugin uninstalled"
ADDON_DEST="$MOD_DIR/$ADDON_NAME"
if [[ -d "$ADDON_DEST" ]]; then
rm -rf "$ADDON_DEST"
echo "FreeCAD Robust MCP Workbench uninstalled from: $ADDON_DEST"
else
echo "Workbench not found at: $ADDON_DEST"
fi
# Check workbench installation status
mcp-status:
#!/usr/bin/env bash
set -euo pipefail
# Determine directories based on OS
if [[ "$OSTYPE" == "darwin"* ]]; then
MOD_DIR="$HOME/Library/Application Support/FreeCAD/Mod"
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
MOD_DIR="$HOME/.local/share/FreeCAD/Mod"
else
MOD_DIR="$APPDATA/FreeCAD/Mod"
fi
echo "=========================================="
echo "MCP Bridge Installation Status"
echo "=========================================="
echo ""
# Check workbench
if [[ -d "$MOD_DIR/FreecadRobustMCP" ]]; then
echo "✓ Workbench addon: INSTALLED"
echo " Path: $MOD_DIR/FreecadRobustMCP"
echo ""
echo "To use:"
echo " 1. Start FreeCAD"
echo " 2. Select 'MCP Bridge' workbench"
echo " 3. Click 'Start MCP Bridge' in toolbar"
else
echo "✗ Workbench addon: NOT INSTALLED"
echo ""
echo "To install:"
echo " just freecad::install-workbench"
fi
echo ""
# Check for legacy installations that should be cleaned up
LEGACY_COUNT=0
if [[ -d "$MOD_DIR/MCPBridge" ]]; then
echo "⚠ Legacy plugin found: $MOD_DIR/MCPBridge"
echo " Run: rm -rf \"$MOD_DIR/MCPBridge\""
((LEGACY_COUNT++)) || true
fi
if [[ "$OSTYPE" == "darwin"* ]]; then
MACRO_DIR="$HOME/Library/Application Support/FreeCAD/Macro"
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
MACRO_DIR="$HOME/.local/share/FreeCAD/Macro"
else
MACRO_DIR="$APPDATA/FreeCAD/Macro"
fi
if [[ -f "$MACRO_DIR/StartMCPBridge.FCMacro" ]]; then
echo "⚠ Legacy macro found: $MACRO_DIR/StartMCPBridge.FCMacro"
echo " Run: rm \"$MACRO_DIR/StartMCPBridge.FCMacro\""
((LEGACY_COUNT++)) || true
fi
if [[ $LEGACY_COUNT -gt 0 ]]; then
echo ""
echo "Note: Legacy installations can be removed. The workbench replaces them."
fi
echo "=========================================="
# Check if the workbench addon is installed (alias for mcp-status)
workbench-status: mcp-status
+35 -19
View File
@@ -1,10 +1,34 @@
# Code quality commands
# Usage: just quality::check, just quality::format, etc.
#
# Note: Tools installed via mise (gitleaks, markdownlint-cli2, etc.) use `mise exec`.
# Python tools use `uv run`. This ensures commands work even without mise shell activation.
# Run all pre-commit checks
check:
check: _check-safety-auth
uv run pre-commit run --all-files
# Verify Safety CLI authentication (skipped in CI where SAFETY_API_KEY is used)
_check-safety-auth:
#!/usr/bin/env bash
# Skip check if SAFETY_API_KEY is set (CI environment)
if [[ -n "${SAFETY_API_KEY:-}" ]]; then
exit 0
fi
# Check if authenticated locally
if ! uv run safety auth status >/dev/null 2>&1; then
echo "ERROR: Safety CLI not authenticated."
echo ""
echo "Safety CLI requires a free account for dependency vulnerability scanning."
echo "Run the following command to authenticate:"
echo ""
echo " uv run safety auth login"
echo ""
echo "This only needs to be done once per machine."
echo "See CLAUDE.md for more details."
exit 1
fi
# Format code with ruff
format:
uv run ruff format src tests
@@ -21,7 +45,7 @@ typecheck:
# Run security scanning (code vulnerabilities)
security:
uv run bandit -c pyproject.toml -r src
uv pip list --format=freeze | uv run safety check --stdin
uv run safety scan --detailed-output
# Run spell checking
spellcheck:
@@ -35,15 +59,15 @@ spellcheck:
secrets: secrets-gitleaks secrets-detect secrets-trufflehog # pragma: allowlist secret
@echo "All secrets scans complete!"
# Run gitleaks secrets scanner
# Run gitleaks secrets scanner (installed via mise)
secrets-gitleaks:
uv run gitleaks detect --config .gitleaks.toml --verbose
mise exec -- gitleaks detect --config .gitleaks.toml --verbose
# Run gitleaks on git history
secrets-gitleaks-history:
uv run gitleaks detect --config .gitleaks.toml --verbose --log-opts="--all"
mise exec -- gitleaks detect --config .gitleaks.toml --verbose --log-opts="--all"
# Run detect-secrets scanner
# Run detect-secrets scanner (installed via uv)
secrets-detect:
uv run detect-secrets scan --baseline .secrets.baseline
@@ -55,26 +79,18 @@ secrets-audit:
secrets-baseline-update:
uv run detect-secrets scan --baseline .secrets.baseline --update
# Run trufflehog for verified secrets
# Run trufflehog for verified secrets (via pre-commit - not installed standalone)
secrets-trufflehog:
uv run trufflehog filesystem . --no-update --only-verified
# Run trufflehog on git history
secrets-trufflehog-history:
uv run trufflehog git file://. --no-update --only-verified
uv run pre-commit run trufflehog --all-files
# =============================================================================
# Markdown Linting
# =============================================================================
# Lint all markdown files
# Lint all markdown files (markdownlint-cli2 installed via mise)
markdown-lint:
uv run markdownlint-cli2 "**/*.md"
mise exec -- markdownlint-cli2 "**/*.md"
# Lint and fix markdown files
markdown-fix:
uv run markdownlint-cli2 --fix "**/*.md"
# Format markdown files with mdformat
markdown-format:
uv run mdformat docs/*.md README.md ARCHITECTURE-MCP.md CLAUDE.md
mise exec -- markdownlint-cli2 --fix "**/*.md"
+12 -19
View File
@@ -2,11 +2,12 @@
# https://just.systems/
#
# Commands are organized into modules:
# just docker::build - Docker commands
# just quality::check - Code quality commands
# just testing::unit - Testing commands
# just freecad::run-gui - FreeCAD commands
# just docker::build - Docker commands
# just quality::check - Code quality commands
# just testing::unit - Testing commands
# just freecad::run-gui - FreeCAD commands
# just documentation::build - Documentation commands
# just coderabbit::review - AI code review commands
#
# Or use the shortcut commands defined below.
@@ -16,6 +17,7 @@ mod quality 'just/quality.just'
mod testing 'just/testing.just'
mod freecad 'just/freecad.just'
mod documentation 'just/documentation.just'
mod coderabbit 'just/coderabbit.just'
# Default recipe - show available commands
default:
@@ -75,9 +77,6 @@ markdown-lint: (quality::markdown-lint)
# Fix markdown files (shortcut for quality::markdown-fix)
markdown-fix: (quality::markdown-fix)
# Format markdown files (shortcut for quality::markdown-format)
markdown-format: (quality::markdown-format)
# Run unit tests (shortcut for testing::unit)
test: (testing::unit)
@@ -99,9 +98,15 @@ test-all: (testing::all)
# Build documentation (shortcut for documentation::build)
docs: (documentation::build)
# Build documentation with strict mode (shortcut for documentation::build-strict)
docs-strict: (documentation::build-strict)
# Serve documentation (shortcut for documentation::serve)
docs-serve: (documentation::serve)
# AI code review of staged changes (shortcut for coderabbit::review)
review: (coderabbit::review)
# =============================================================================
# Running the MCP Server
# =============================================================================
@@ -128,24 +133,12 @@ run-gui: (freecad::run-gui)
# Run FreeCAD headless with MCP bridge (shortcut for freecad::run-headless)
run-headless: (freecad::run-headless)
# Install the StartMCPBridge macro (shortcut for freecad::install-bridge-macro)
install-bridge-macro: (freecad::install-bridge-macro)
# Uninstall the StartMCPBridge macro (shortcut for freecad::uninstall-bridge-macro)
uninstall-bridge-macro: (freecad::uninstall-bridge-macro)
# Install the CutObjectForMagnets macro (shortcut for freecad::install-cut-macro)
install-cut-macro: (freecad::install-cut-macro)
# Uninstall the CutObjectForMagnets macro (shortcut for freecad::uninstall-cut-macro)
uninstall-cut-macro: (freecad::uninstall-cut-macro)
# Install the FreeCAD plugin (shortcut for freecad::install-plugin)
install-freecad-plugin: (freecad::install-plugin)
# Uninstall the FreeCAD plugin (shortcut for freecad::uninstall-plugin)
uninstall-freecad-plugin: (freecad::uninstall-plugin)
# =============================================================================
# Combined Workflows
# =============================================================================
@@ -1,8 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<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"/>
<!-- Top piece of cut object -->
<g id="top-piece">
<!-- Main body -->
@@ -11,11 +10,9 @@
<circle cx="24" cy="26" r="3" fill="#1a252f"/>
<circle cx="40" cy="26" r="3" fill="#1a252f"/>
</g>
<!-- Cut line indicator -->
<line x1="8" y1="32" x2="56" y2="32" stroke="#e74c3c" stroke-width="2" stroke-dasharray="4,2"/>
<polygon points="54,32 50,29 50,35" fill="#e74c3c"/>
<!-- Bottom piece of cut object -->
<g id="bottom-piece">
<!-- Main body -->
@@ -23,12 +20,10 @@
<!-- Connector holes (visible from top) -->
<circle cx="24" cy="38" r="3" fill="#1a252f"/>
<circle cx="40" cy="38" r="3" fill="#1a252f"/>
<!-- Depth indicators (showing holes go into the part) -->
<ellipse cx="24" cy="38" rx="3" ry="1.5" fill="#34495e" opacity="0.6"/>
<ellipse cx="40" cy="38" rx="3" ry="1.5" fill="#34495e" opacity="0.6"/>
</g>
<!-- Small connector/magnet indicators -->
<circle cx="24" cy="26" r="2" fill="#95a5a6" stroke="#7f8c8d" stroke-width="0.5"/>
<circle cx="40" cy="26" r="2" fill="#95a5a6" stroke="#7f8c8d" stroke-width="0.5"/>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+2 -44
View File
@@ -153,50 +153,7 @@ ToolBar Icon [[Image:Macro_Cut_Object_For_Magnets.png]]
'''Macro_Cut_Object_For_Magnets.FCMacro'''
{{MacroCode|code=
"""FreeCAD Macro: Cut Object for Magnets.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
Cuts an object along a plane and adds connector holes for magnets with
surface collision detection.
Requirements:
- FreeCAD 0.19 or later
- An object selected in the 3D view
Usage:
1. Select the object to cut
2. Run the macro
3. Configure cut plane and hole parameters
4. Click "Execute Cut"
"""
# FreeCAD Addon Manager metadata
__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"
__License__ = "MIT"
__Web__ = "https://github.com/spkane/freecad-robust-mcp-and-more"
__Wiki__ = "https://github.com/spkane/freecad-robust-mcp-and-more#readme"
__Icon__ = ""
__Help__ = "Select an object to cut, run the macro, configure cut plane and magnet hole parameters, then click Execute Cut. Creates two parts with aligned magnet holes."
__Status__ = "Beta"
__Requires__ = "FreeCAD 0.19+"
__Communication__ = "https://github.com/spkane/freecad-robust-mcp-and-more/issues"
__Files__ = ""
import FreeCAD as App
import FreeCADGui as Gui
import Part
from PySide import QtGui
# ... (full macro code continues - see GitHub repository for complete source)
# https://github.com/spkane/freecad-robust-mcp-and-more/blob/main/macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro
}}
- https://github.com/spkane/freecad-robust-mcp-and-more/blob/main/macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro
==Links== <!--T:25-->
@@ -207,3 +164,4 @@ from PySide import QtGui
[[Category:Macros{{#translation:}}]]
[[Category:User Documentation{{#translation:}}]]
[[Category:Addons {{#translation:}}]]
+1 -9
View File
@@ -1,8 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<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"/>
<!-- 3D Object representation (cube) -->
<g id="source-object" transform="translate(8, 8)">
<!-- Cube top face -->
@@ -12,13 +11,11 @@
<!-- Cube left face -->
<polygon points="4,10 16,16 16,28 4,22" fill="#5dade2" stroke="#2980b9" stroke-width="1"/>
</g>
<!-- Export arrow -->
<g id="export-arrow">
<line x1="32" y1="26" x2="32" y2="38" stroke="#ecf0f1" stroke-width="2"/>
<polygon points="32,42 26,36 38,36" fill="#ecf0f1"/>
</g>
<!-- Multiple file format outputs -->
<!-- STL file icon -->
<g id="stl-file" transform="translate(6, 44)">
@@ -26,29 +23,24 @@
<rect x="0" y="0" width="14" height="4" fill="#1e8449" rx="1"/>
<text x="7" y="12" font-family="Arial, sans-serif" font-size="5" fill="white" text-anchor="middle" font-weight="bold">STL</text>
</g>
<!-- STEP file icon -->
<g id="step-file" transform="translate(25, 44)">
<rect x="0" y="0" width="14" height="16" fill="#e67e22" stroke="#d35400" stroke-width="1" rx="1"/>
<rect x="0" y="0" width="14" height="4" fill="#d35400" rx="1"/>
<text x="7" y="12" font-family="Arial, sans-serif" font-size="4" fill="white" text-anchor="middle" font-weight="bold">STEP</text>
</g>
<!-- 3MF file icon -->
<g id="3mf-file" transform="translate(44, 44)">
<rect x="0" y="0" width="14" height="16" fill="#9b59b6" stroke="#8e44ad" stroke-width="1" rx="1"/>
<rect x="0" y="0" width="14" height="4" fill="#8e44ad" rx="1"/>
<text x="7" y="12" font-family="Arial, sans-serif" font-size="4" fill="white" text-anchor="middle" font-weight="bold">3MF</text>
</g>
<!-- Checkmarks on files to indicate selection -->
<g id="checkmarks" fill="#2ecc71" stroke="#27ae60" stroke-width="0.5">
<circle cx="17" cy="47" r="3" fill="#2ecc71"/>
<path d="M15.5,47 L16.5,48 L18.5,46" stroke="white" stroke-width="1" fill="none"/>
<circle cx="36" cy="47" r="3" fill="#2ecc71"/>
<path d="M34.5,47 L35.5,48 L37.5,46" stroke="white" stroke-width="1" fill="none"/>
<circle cx="55" cy="47" r="3" fill="#2ecc71"/>
<path d="M53.5,47 L54.5,48 L56.5,46" stroke="white" stroke-width="1" fill="none"/>
</g>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

+2 -77
View File
@@ -87,83 +87,7 @@ ToolBar Icon [[Image:Macro_Multi_Export.png]]
'''Macro_Multi_Export.FCMacro'''
{{MacroCode|code=
"""FreeCAD Macro: Multi-Format Export.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
Export selected bodies to multiple file formats simultaneously with a
user-friendly dialog for format selection and output configuration.
Requirements:
- FreeCAD 0.21 or later
- One or more objects selected in the 3D view
Usage:
1. Select the object(s) to export
2. Run the macro
3. Select desired export formats (STL, STEP, 3MF selected by default)
4. Choose output directory and base filename
5. Click "Export"
"""
# FreeCAD Addon Manager metadata
__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"
__License__ = "MIT"
__Web__ = "https://github.com/spkane/freecad-robust-mcp-and-more"
__Wiki__ = "https://github.com/spkane/freecad-robust-mcp-and-more#readme"
__Icon__ = ""
__Help__ = "Select one or more objects, run the macro, choose export formats and output location, then click Export."
__Status__ = "Beta"
__Requires__ = "FreeCAD 0.21+"
__Communication__ = "https://github.com/spkane/freecad-robust-mcp-and-more/issues"
__Files__ = ""
import os
import FreeCAD as App
import FreeCADGui as Gui
import Mesh
import Part
from PySide import QtGui
class ExportFormat:
"""Represents an export format with its properties."""
def __init__(
self,
name: str,
extension: str,
description: str,
default_enabled: bool = False,
):
self.name = name
self.extension = extension
self.description = description
self.default_enabled = default_enabled
# Define available export formats
EXPORT_FORMATS = [
ExportFormat("STL", "stl", "Stereolithography - common for 3D printing", default_enabled=True),
ExportFormat("STEP", "step", "Standard for Exchange of Product Data - CAD interchange", default_enabled=True),
ExportFormat("3MF", "3mf", "3D Manufacturing Format - modern 3D printing format", default_enabled=True),
ExportFormat("OBJ", "obj", "Wavefront OBJ - 3D graphics and game engines", default_enabled=False),
ExportFormat("IGES", "iges", "Initial Graphics Exchange Specification - legacy CAD format", default_enabled=False),
ExportFormat("BREP", "brep", "OpenCASCADE native format - preserves exact geometry", default_enabled=False),
ExportFormat("PLY", "ply", "Polygon File Format - 3D scanning and printing", default_enabled=False),
ExportFormat("AMF", "amf", "Additive Manufacturing Format - XML-based 3D printing", default_enabled=False),
]
# ... (full macro code continues - see GitHub repository for complete source)
# https://github.com/spkane/freecad-robust-mcp-and-more/blob/main/macros/Multi_Export/MultiExport.FCMacro
}}
- https://github.com/spkane/freecad-robust-mcp-and-more/blob/main/macros/Multi_Export/MultiExport.FCMacro
==Links== <!--T:16-->
@@ -173,3 +97,4 @@ EXPORT_FORMATS = [
[[Category:Macros{{#translation:}}]]
[[Category:User Documentation{{#translation:}}]]
[[Category:Addons {{#translation:}}]]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,138 +0,0 @@
# Start MCP Bridge - FreeCAD Macro
**Version:** 1.0.0
**FreeCAD Version:** 0.21 or later
**License:** MIT
## Overview
This FreeCAD macro starts the MCP (Model Context Protocol) bridge server, enabling integration between AI assistants (Claude, GPT, and other MCP-compatible tools) and FreeCAD. Once running, AI assistants can control FreeCAD, create and modify 3D models, execute Python code, and more.
## Quick Start
### Installation
```bash
# From the freecad-mcp project directory
just install-bridge-macro
```
### Usage
1. Start FreeCAD
1. Go to **Macro -> Macros...**
1. Select **StartMCPBridge**
1. Click **Execute**
You should see in the FreeCAD console:
```text
MCP Bridge started!
- XML-RPC: localhost:9875
- Socket: localhost:9876
You can now use AI assistants with FreeCAD.
```
### Connecting Your MCP Client
After starting the bridge, configure your MCP client (e.g., Claude Code) with:
```json
{
"mcpServers": {
"freecad": {
"command": "uv",
"args": ["run", "--project", "/path/to/freecad-robust-mcp-and-more", "freecad-mcp"],
"env": {
"FREECAD_MODE": "xmlrpc"
}
}
}
}
```
## Connection Modes
The bridge starts two servers:
| Port | Protocol | Description |
| ---- | -------- | ------------------------------------- |
| 9875 | XML-RPC | Primary connection mode (recommended) |
| 9876 | JSON-RPC | Alternative socket-based connection |
Configure your MCP client's `FREECAD_MODE` environment variable:
- `xmlrpc` (default) - Uses port 9875
- `socket` - Uses port 9876
## Alternative: Automatic Startup
Instead of running the macro manually each time, you can:
### Option 1: Use `just run-gui`
```bash
just run-gui
```
This starts FreeCAD with the bridge auto-started.
### Option 2: Use `just run-headless`
```bash
just run-headless
```
This starts FreeCAD in headless/console mode with the bridge running. Useful for automation and CI/CD.
## Troubleshooting
### Error: "MCP Bridge macro not properly installed"
The macro wasn't installed correctly. Run:
```bash
just install-bridge-macro
```
### Error: "Failed to import MCP Bridge module"
The freecad-mcp project path is incorrect or the module isn't installed. Ensure:
1. You installed using `just install-bridge-macro` from the project directory
1. The project's Python dependencies are installed (`uv sync`)
### Bridge won't start
Check the FreeCAD console for error messages. Common issues:
1. **Port already in use** - Another instance is running, or another application is using ports 9875/9876
1. **Python path issues** - The project src directory isn't accessible
### MCP client can't connect
1. Ensure the bridge is running (check FreeCAD console)
1. Verify your MCP client configuration
1. Restart your MCP client after configuration changes
## Uninstallation
```bash
just uninstall-bridge-macro
```
## Technical Details
The macro:
1. Adds the freecad-mcp project source to Python's path
1. Imports and instantiates the `FreecadMCPPlugin`
1. Starts both XML-RPC and JSON-RPC servers
1. Registers handlers for executing Python code, managing documents, creating objects, etc.
The bridge runs in FreeCAD's main thread using Qt timers for non-blocking operation.
## License
MIT License - Free to use, modify, and distribute.
@@ -1,85 +0,0 @@
"""FreeCAD Macro: Start MCP Bridge Server.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
Start the MCP bridge server for AI assistant integration with FreeCAD.
Requirements:
- FreeCAD 0.21 or later
- freecad-mcp project installed and accessible
Usage:
1. Install via: just install-bridge-macro
2. Run the macro from: Macro -> Macros -> StartMCPBridge -> Execute
3. Connect your MCP client (Claude Code, etc.) to the running bridge
"""
# FreeCAD Addon Manager metadata
__Name__ = "Start MCP Bridge"
__Comment__ = "Start the MCP bridge server for AI assistant integration with FreeCAD"
__Author__ = "Sean P. Kane"
__Version__ = "0.5.0-beta"
__Date__ = "2026-01-05"
__License__ = "MIT"
__Web__ = "https://github.com/spkane/freecad-robust-mcp-and-more"
__Wiki__ = "https://github.com/spkane/freecad-robust-mcp-and-more#readme"
__Icon__ = ""
__Help__ = "Run this macro to start the MCP bridge server on ports 9875 (XML-RPC) and 9876 (Socket). Connect your MCP client (Claude Code, etc.) to the running bridge."
__Status__ = "Beta"
__Requires__ = "FreeCAD 0.21+"
__Communication__ = "https://github.com/spkane/freecad-robust-mcp-and-more/issues"
__Files__ = ""
import sys
import FreeCAD
def start_mcp_bridge():
"""Start the MCP bridge server for AI assistant integration."""
# The project path is injected during macro installation
# This placeholder will be replaced with the actual path
project_path = "__PROJECT_PATH__"
if project_path == "__PROJECT_PATH__":
FreeCAD.Console.PrintError(
"MCP Bridge macro not properly installed.\n"
"Please run: just install-bridge-macro\n"
)
return
if project_path not in sys.path:
sys.path.insert(0, project_path)
try:
# Import and start the plugin
from freecad_mcp.freecad_plugin.server import FreecadMCPPlugin
# Create and start the plugin
plugin = FreecadMCPPlugin(
host="localhost",
port=9876, # JSON-RPC socket port
xmlrpc_port=9875, # XML-RPC port (neka-nat compatible)
enable_xmlrpc=True,
)
plugin.start()
FreeCAD.Console.PrintMessage("MCP Bridge started!\n")
FreeCAD.Console.PrintMessage(" - XML-RPC: localhost:9875\n")
FreeCAD.Console.PrintMessage(" - Socket: localhost:9876\n")
FreeCAD.Console.PrintMessage(
"\nYou can now connect your MCP client (Claude Code, etc.) to FreeCAD.\n"
)
except ImportError as e:
FreeCAD.Console.PrintError(
f"Failed to import MCP Bridge module: {e}\n"
f"Ensure the freecad-mcp project is accessible at: {project_path}\n"
)
except Exception as e:
FreeCAD.Console.PrintError(f"Failed to start MCP Bridge: {e}\n")
if __name__ == "__main__":
start_mcp_bridge()
-252
View File
@@ -1,252 +0,0 @@
<languages/>
<translate>
<!--T:1-->
{{Macro
|Name=Macro Start MCP Bridge
|Icon=Macro_Start_MCP_Bridge.png
|Description=Start the MCP (Model Context Protocol) bridge server which is part of the [https://github.com/spkane/freecad-robust-mcp-and-more FreeCAD Robust MCP server] which helps facilitate AI assistant integration with FreeCAD. Enables AI tools like Claude Code to control FreeCAD programmatically.
|Author=Sean P. Kane
|Version=0.5.0-beta
|Date=2026-01-05
|FCVersion=0.21+
|Download=[https://wiki.freecad.org/images/thumb/d/d7/Macro_Start_MCP_Bridge.png/48px-Macro_Start_MCP_Bridge.png ToolBar Icon]
|SeeAlso=[[Python_scripting_tutorial|Python Scripting Tutorial]]
}}
==Description== <!--T:2-->
<!--T:3-->
This macro starts the MCP (Model Context Protocol) bridge server which is part of the [https://github.com/spkane/freecad-robust-mcp-and-more FreeCAD Robust MCP server] on GitHub. This enables AI assistants like [https://claude.com/claude-code Claude Code] and other MCP-compatible clients to control FreeCAD programmatically.
<!--T:4-->
'''What is MCP?'''
The Model Context Protocol (MCP) is an open standard developed by Anthropic that allows AI assistants to interact with external tools and applications. With this bridge running, an AI assistant can:
* Create and manipulate FreeCAD documents
* Build 3D models using Part and PartDesign workbenches
* Execute Python code within FreeCAD's environment
* Export models to various formats (STEP, STL, 3MF, etc.)
* Query object properties and document state
<!--T:5-->
'''Server Ports:'''
* '''XML-RPC''' (port 9875): Compatible with neka-nat's original freecad-mcp implementation
* '''JSON-RPC Socket''' (port 9876): Modern protocol for MCP clients
<!--T:6-->
'''Key Features:'''
* Dual protocol support (XML-RPC and JSON-RPC)
* Full access to FreeCAD's Python API
* Safe code execution with result capture
* Console output streaming
* Works with Claude Code, and other MCP-compatible AI tools
==Usage== <!--T:7-->
<!--T:8-->
'''Prerequisites:'''
# Install the freecad-robust-mcp package from PyPI or GitHub
# Configure the macro with the correct project path (done automatically by the installer)
<!--T:9-->
'''Starting the Bridge:'''
# Open FreeCAD
# Run the macro from '''Macro → Macros → StartMCPBridge → Execute'''
# The console will show:
#: {{Code|code=
MCP Bridge started!
- XML-RPC: localhost:9875
- Socket: localhost:9876
You can now connect your MCP client (Claude Code, etc.) to FreeCAD.
}}
<!--T:10-->
'''Connecting an AI Client:'''
For Claude Code, add this to your MCP settings:
{{Code|code=
{
"mcpServers": {
"freecad": {
"command": "freecad-mcp",
"args": ["--mode", "xmlrpc"]
}
}
}
}}
==Requirements== <!--T:11-->
<!--T:12-->
* FreeCAD 0.21 or later
* Python 3.11+ (must match FreeCAD's bundled Python version)
* The freecad-robust-mcp package installed
==Installation== <!--T:13-->
<!--T:14-->
'''Method 1: Using the Installer Script'''
If you have the freecad-robust-mcp project cloned locally:
{{Code|code=
just install-bridge-macro
}}
This automatically:
* Copies the macro to your FreeCAD macro directory
* Injects the correct project path
* Sets up the icon
<!--T:15-->
'''Method 2: Manual Installation'''
# Install the freecad-robust-mcp package:
#: {{Code|code=
pip install freecad-robust-mcp
}}
# Download the macro file: [[Media:StartMCPBridge.FCMacro|StartMCPBridge.FCMacro]]
# Edit the macro and replace {{Incode|__PROJECT_PATH__}} with the actual path to the installed package
# Copy the file to your FreeCAD macro directory:
#* '''macOS''': {{FileName|~/Library/Application Support/FreeCAD/Macro/}}
#* '''Linux''': {{FileName|~/.local/share/FreeCAD/Macro/}}
#* '''Windows''': {{FileName|%APPDATA%/FreeCAD/Macro/}}
==Troubleshooting== <!--T:16-->
<!--T:17-->
'''Error: "MCP Bridge macro not properly installed"'''
The project path placeholder was not replaced during installation. Run:
{{Code|code=
just install-bridge-macro
}}
<!--T:18-->
'''Error: "Failed to import MCP Bridge module"'''
The freecad-robust-mcp package is not installed or not accessible. Ensure:
* The package is installed: {{Incode|pip install freecad-robust-mcp}}
* The project path in the macro points to the correct location
<!--T:19-->
'''Connection Issues'''
* Verify the bridge is running (check the FreeCAD console for startup messages)
* Ensure no firewall is blocking ports 9875 and 9876
* Try connecting with a simple test: {{Incode|curl http://localhost:9875}}
==Source Code== <!--T:20-->
<!--T:21-->
The full source code is hosted on GitHub:
* [https://github.com/spkane/freecad-robust-mcp-and-more/blob/main/macros/Start_MCP_Bridge/StartMCPBridge.FCMacro StartMCPBridge.FCMacro on GitHub]
* [https://github.com/spkane/freecad-robust-mcp-and-more GitHub Repository (freecad-robust-mcp-and-more)]
==Script== <!--T:22-->
<!--T:23-->
ToolBar Icon [[Image:Macro_Start_MCP_Bridge.png]]
</translate>
'''Macro_Start_MCP_Bridge.FCMacro'''
{{MacroCode|code=
"""FreeCAD Macro: Start MCP Bridge Server.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
Start the MCP bridge server for AI assistant integration with FreeCAD.
Requirements:
- FreeCAD 0.21 or later
- freecad-mcp project installed and accessible
Usage:
1. Install via: just install-bridge-macro
2. Run the macro from: Macro -> Macros -> StartMCPBridge -> Execute
3. Connect your MCP client (Claude Code, etc.) to the running bridge
"""
# FreeCAD Addon Manager metadata
__Name__ = "Start MCP Bridge"
__Comment__ = "Start the MCP bridge server for AI assistant integration with FreeCAD"
__Author__ = "Sean P. Kane"
__Version__ = "0.5.0-beta"
__Date__ = "2026-01-05"
__License__ = "MIT"
__Web__ = "https://github.com/spkane/freecad-robust-mcp-and-more"
__Wiki__ = "https://github.com/spkane/freecad-robust-mcp-and-more#readme"
__Icon__ = ""
__Help__ = "Run this macro to start the MCP bridge server on ports 9875 (XML-RPC) and 9876 (Socket). Connect your MCP client (Claude Code, etc.) to the running bridge."
__Status__ = "Beta"
__Requires__ = "FreeCAD 0.21+"
__Communication__ = "https://github.com/spkane/freecad-robust-mcp-and-more/issues"
__Files__ = ""
import sys
import FreeCAD
def start_mcp_bridge():
"""Start the MCP bridge server for AI assistant integration."""
# The project path is injected during macro installation
# This placeholder will be replaced with the actual path
project_path = "__PROJECT_PATH__"
if project_path == "__PROJECT_PATH__":
FreeCAD.Console.PrintError(
"MCP Bridge macro not properly installed.\n"
"Please run: just install-bridge-macro\n"
)
return
if project_path not in sys.path:
sys.path.insert(0, project_path)
try:
# Import and start the plugin
from freecad_mcp.freecad_plugin.server import FreecadMCPPlugin
# Create and start the plugin
plugin = FreecadMCPPlugin(
host="localhost",
port=9876, # JSON-RPC socket port
xmlrpc_port=9875, # XML-RPC port (neka-nat compatible)
enable_xmlrpc=True,
)
plugin.start()
FreeCAD.Console.PrintMessage("MCP Bridge started!\n")
FreeCAD.Console.PrintMessage(" - XML-RPC: localhost:9875\n")
FreeCAD.Console.PrintMessage(" - Socket: localhost:9876\n")
FreeCAD.Console.PrintMessage(
"\nYou can now connect your MCP client (Claude Code, etc.) to FreeCAD.\n"
)
except ImportError as e:
FreeCAD.Console.PrintError(
f"Failed to import MCP Bridge module: {e}\n"
f"Ensure the freecad-mcp project is accessible at: {project_path}\n"
)
except Exception as e:
FreeCAD.Console.PrintError(f"Failed to start MCP Bridge: {e}\n")
if __name__ == "__main__":
start_mcp_bridge()
}}
==Links== <!--T:24-->
<!--T:25-->
* [https://modelcontextprotocol.io/ Model Context Protocol Specification]
* [https://claude.com/claude-code Claude Code] - AI assistant that works with MCP
* [[Python_scripting_tutorial|Python Scripting Tutorial]] - FreeCAD Python documentation
[[Category:Macros{{#translation:}}]]
[[Category:User Documentation{{#translation:}}]]
+14 -5
View File
@@ -33,7 +33,11 @@ theme:
markdown_extensions:
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.superfences
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
- pymdownx.inlinehilite
- pymdownx.snippets
- admonition
@@ -62,10 +66,15 @@ nav:
- Configuration: getting-started/configuration.md
- Quick Start: getting-started/quickstart.md
- User Guide:
- Embedded Mode: guide/embedded-mode.md
- Socket Mode: guide/socket-mode.md
- Tools Reference: guide/tools.md
- Resources Reference: guide/resources.md
- Connection Modes: guide/connection-modes.md
- MCP Bridge Workbench: guide/workbench.md
- FreeCAD Macros: guide/macros.md
- Tools Overview: guide/tools.md
- MCP Resources: guide/resources.md
- Detailed User Guide: USER_GUIDE.md
- Tools Reference: MCP_TOOLS_REFERENCE.md
- Comparisons:
- Other Implementations: COMPARISON.md
- API Reference:
- Server: api/server.md
- Bridge: api/bridge.md
+65
View File
@@ -0,0 +1,65 @@
<?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>
<version>0.5.0-beta</version>
<date>2026-01-05</date>
<maintainer email="spkane@gmail.com">Sean P. Kane</maintainer>
<author email="spkane@gmail.com">Sean P. Kane</author>
<license file="LICENSE">MIT</license>
<freecadmin>0.21</freecadmin>
<url type="repository" branch="main">https://github.com/spkane/freecad-robust-mcp-and-more</url>
<url type="bugtracker">https://github.com/spkane/freecad-robust-mcp-and-more/issues</url>
<url type="readme">https://github.com/spkane/freecad-robust-mcp-and-more/blob/main/README.md</url>
<url type="documentation">https://github.com/spkane/freecad-robust-mcp-and-more#readme</url>
<tag>workbench</tag>
<tag>macro</tag>
<tag>export</tag>
<tag>3D printing</tag>
<tag>magnets</tag>
<tag>MCP</tag>
<tag>AI</tag>
<tag>automation</tag>
<tag>Claude</tag>
<tag>bridge</tag>
<tag>headless</tag>
<tag>scripting</tag>
<content>
<workbench>
<name>MCP Bridge</name>
<description>MCP (Model Context Protocol) bridge workbench for AI assistant integration with FreeCAD. Works in both GUI mode (with full visual features including screenshots, colors, and camera control) and headless mode (for automation and CI/CD). Provides toolbar commands to start, stop, and monitor the MCP bridge server. Supports XML-RPC and JSON-RPC protocols. Connect Claude Code, Cursor, or other MCP-compatible AI assistants to control FreeCAD programmatically with 82+ CAD tools.</description>
<classname>FreecadRobustMCPWorkbench</classname>
<subdirectory>./addon/FreecadRobustMCP/</subdirectory>
<icon>FreecadRobustMCP.svg</icon>
<freecadmin>0.21</freecadmin>
</workbench>
<macro>
<name>Multi Export</name>
<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>
<icon>MultiExport.svg</icon>
</macro>
<macro>
<name>Cut Object for Magnets</name>
<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>
<icon>CutObjectForMagnets.svg</icon>
</macro>
</content>
</package>
+8
View File
@@ -61,19 +61,27 @@ dependencies = [
[project.optional-dependencies]
dev = [
# Testing
"pytest>=8.3.0",
"pytest-asyncio>=0.25.0",
"pytest-cov>=6.0.0",
"pytest-timeout>=2.3.0",
# Linting and formatting
"mypy>=1.14.0",
"ruff>=0.8.0",
# Security scanning
"bandit[toml]>=1.8.0",
"safety>=3.2.0",
"detect-secrets>=1.5.0",
# Pre-commit
"pre-commit>=4.0.0",
# Documentation
"mkdocs>=1.6.0",
"mkdocs-material>=9.5.0",
"mkdocstrings[python]>=0.27.0",
"md-toc>=9.0.0",
"codespell>=2.3.0",
# Publishing
"twine>=6.0.0",
"build>=1.2.0",
]
+8 -11
View File
@@ -118,25 +118,22 @@ The FreeCAD MCP bridge server is not running. To fix this:
1. Start FreeCAD (the GUI application)
2. In FreeCAD's Python console (View → Panels → Python console), run:
2. Start the MCP bridge using one of these methods:
import sys
sys.path.insert(0, "/path/to/freecad-robust-mcp-and-more/src")
from freecad_mcp.freecad_plugin.server import FreecadMCPPlugin
plugin = FreecadMCPPlugin()
plugin.start()
Option A: Using the MCP Bridge Workbench (recommended)
- Install via FreeCAD Addon Manager: Tools → Addon Manager
- Search for "MCP Bridge" and install
- Switch to the MCP Bridge workbench
- Click "Start MCP Bridge" in the toolbar
Or run the StartMCPBridge macro if installed:
- Macro → Macros → StartMCPBridge → Execute
Option B: From source (for developers)
- Run: just run-gui
3. You should see: "MCP Bridge started!"
- XML-RPC: localhost:{self._port}
- Socket: localhost:9876
4. Then restart your MCP client (e.g., restart Claude Code)
To install the macro automatically, run from the project directory:
just install-macro
================================================================================
"""
@@ -1,58 +0,0 @@
"""FreeCAD MCP Bridge Plugin.
This package is installed into FreeCAD's Mod directory to provide
socket and XML-RPC servers for MCP communication.
Based on learnings from competitive analysis:
- Queue-based GUI communication for thread safety (from neka-nat)
- JSON-RPC 2.0 protocol for modern integration (port 9876)
- XML-RPC compatibility mode for neka-nat addons (port 9875)
To install, copy this directory to:
- macOS: ~/Library/Application Support/FreeCAD/Mod/MCPBridge/
- Linux: ~/.local/share/FreeCAD/Mod/MCPBridge/
- Windows: %APPDATA%/FreeCAD/Mod/MCPBridge/
Or use the justfile command:
just install-freecad-plugin
"""
from freecad_mcp.freecad_plugin.server import (
DEFAULT_SOCKET_PORT,
DEFAULT_XMLRPC_PORT,
FreecadMCPPlugin,
)
__all__ = [
"DEFAULT_SOCKET_PORT",
"DEFAULT_XMLRPC_PORT",
"FreecadMCPPlugin",
"start",
]
def start(
host: str = "localhost",
port: int = DEFAULT_SOCKET_PORT,
xmlrpc_port: int = DEFAULT_XMLRPC_PORT,
enable_xmlrpc: bool = True,
) -> FreecadMCPPlugin:
"""Start the MCP bridge servers.
Args:
host: Hostname to bind to.
port: Port for JSON-RPC socket server.
xmlrpc_port: Port for XML-RPC server.
enable_xmlrpc: Whether to enable XML-RPC server.
Returns:
The running plugin instance.
"""
plugin = FreecadMCPPlugin(
host=host,
port=port,
xmlrpc_port=xmlrpc_port,
enable_xmlrpc=enable_xmlrpc,
)
plugin.start()
return plugin
@@ -1,62 +0,0 @@
#!/usr/bin/env python3
"""FreeCAD MCP Bridge Auto-Start Script for GUI mode.
This script is run automatically when FreeCAD GUI starts via `just run-gui`.
It starts the MCP bridge servers to allow AI assistants to communicate with FreeCAD.
Usage:
This script is passed to FreeCAD as a startup script:
/Applications/FreeCAD.app/Contents/Resources/bin/freecad gui_startup.py
Note: This script imports FreecadMCPPlugin directly from server.py to avoid
triggering the MCP SDK import in freecad_mcp/__init__.py (which isn't available
in FreeCAD's embedded Python environment).
"""
from __future__ import annotations
import sys
from pathlib import Path
# Check if we're running inside FreeCAD
try:
import FreeCAD
except ImportError:
print("ERROR: This script must be run inside FreeCAD.")
sys.exit(1)
# Import the plugin server directly from the module file
# We avoid importing through the package hierarchy (freecad_mcp.freecad_plugin.server)
# because freecad_mcp/__init__.py imports the MCP SDK which isn't available
# in FreeCAD's embedded Python environment
script_dir = str(Path(__file__).resolve().parent)
sys.path.insert(0, script_dir)
# Import and start the plugin
try:
from server import FreecadMCPPlugin # Direct import from same directory
# Create and start the plugin
plugin = FreecadMCPPlugin(
host="localhost",
port=9876, # JSON-RPC socket port
xmlrpc_port=9875, # XML-RPC port (neka-nat compatible)
enable_xmlrpc=True,
)
plugin.start()
FreeCAD.Console.PrintMessage("\n")
FreeCAD.Console.PrintMessage("=" * 60 + "\n")
FreeCAD.Console.PrintMessage("MCP Bridge started!\n")
FreeCAD.Console.PrintMessage(" - XML-RPC: localhost:9875\n")
FreeCAD.Console.PrintMessage(" - Socket: localhost:9876\n")
FreeCAD.Console.PrintMessage("\n")
FreeCAD.Console.PrintMessage(
"You can now connect your MCP client (Claude Code, etc.) to FreeCAD.\n"
)
FreeCAD.Console.PrintMessage("=" * 60 + "\n")
except Exception as e:
FreeCAD.Console.PrintError(f"Failed to start MCP Bridge: {e}\n")
import traceback
traceback.print_exc()
+1
View File
@@ -0,0 +1 @@
"""Unit tests for the FreeCAD Robust MCP workbench addon."""
+187
View File
@@ -0,0 +1,187 @@
"""Tests for the FreeCAD Robust MCP workbench addon structure.
These tests verify that the addon has the correct file structure and
that the Python files are valid (can be parsed).
"""
import ast
from pathlib import Path
import pytest
# Get the addon directory path
ADDON_DIR = Path(__file__).parent.parent.parent.parent / "addon" / "FreecadRobustMCP"
class TestAddonFileStructure:
"""Tests for addon file structure."""
def test_addon_directory_exists(self):
"""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):
"""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):
"""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):
"""The workbench icon should exist."""
icon_file = ADDON_DIR / "FreecadRobustMCP.svg"
assert icon_file.exists(), f"Icon not found: {icon_file}"
def test_bridge_module_exists(self):
"""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):
"""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):
"""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_headless_server_exists(self):
"""The headless_server.py should exist for headless mode support."""
headless_file = ADDON_DIR / "freecad_mcp_bridge" / "headless_server.py"
assert headless_file.exists(), f"headless_server.py not found: {headless_file}"
class TestAddonPythonSyntax:
"""Tests to verify Python files have valid syntax."""
def test_init_py_valid_syntax(self):
"""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):
"""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):
"""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):
"""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_headless_server_valid_syntax(self):
"""headless_server.py should have valid Python syntax."""
headless_file = ADDON_DIR / "freecad_mcp_bridge" / "headless_server.py"
code = headless_file.read_text()
ast.parse(code)
class TestAddonMetadata:
"""Tests for addon metadata and content."""
def test_init_py_has_freecad_import(self):
"""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):
"""InitGui.py should define the workbench class."""
initgui_file = ADDON_DIR / "InitGui.py"
code = initgui_file.read_text()
assert "FreecadRobustMCPWorkbench" in code
assert "Gui.Workbench" in code or "Workbench" in code
def test_initgui_py_has_commands(self):
"""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):
"""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):
"""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_headless_server_imports_plugin(self):
"""headless_server.py should import FreecadMCPPlugin."""
headless_file = ADDON_DIR / "freecad_mcp_bridge" / "headless_server.py"
code = headless_file.read_text()
assert "FreecadMCPPlugin" in code
def test_headless_server_has_run_forever(self):
"""headless_server.py should call run_forever for blocking execution."""
headless_file = ADDON_DIR / "freecad_mcp_bridge" / "headless_server.py"
code = headless_file.read_text()
assert "run_forever" in code
def test_icon_is_valid_svg(self):
"""The icon should be a valid SVG file."""
icon_file = ADDON_DIR / "FreecadRobustMCP.svg"
content = icon_file.read_text()
assert content.startswith("<?xml") or content.startswith("<svg")
assert "<svg" in content
assert "</svg>" in content
class TestAddonIconSize:
"""Tests for addon icon size requirements."""
def test_icon_size_under_10kb(self):
"""The icon file should be under 10KB (FreeCAD requirement)."""
icon_file = ADDON_DIR / "FreecadRobustMCP.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"
class TestPackageXml:
"""Tests for package.xml workbench entry."""
@pytest.fixture
def package_xml(self):
"""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):
"""package.xml should have a workbench entry."""
assert "<workbench>" in package_xml
def test_workbench_classname(self, package_xml):
"""package.xml should reference the correct workbench classname."""
assert "<classname>FreecadRobustMCPWorkbench</classname>" in package_xml
def test_workbench_subdirectory(self, package_xml):
"""package.xml should reference the correct subdirectory."""
assert "./addon/FreecadRobustMCP/" in package_xml
def test_workbench_icon(self, package_xml):
"""package.xml should reference the workbench icon."""
assert "<icon>FreecadRobustMCP.svg</icon>" in package_xml
+439
View File
@@ -0,0 +1,439 @@
"""Tests for MCP resources module."""
import json
from collections.abc import Callable
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from freecad_mcp.bridge.base import (
ConnectionStatus,
DocumentInfo,
MacroInfo,
ObjectInfo,
WorkbenchInfo,
)
class TestFreecadResources:
"""Tests for FreeCAD MCP resources."""
@pytest.fixture
def mock_mcp(self) -> MagicMock:
"""Create a mock MCP server that captures resource registrations."""
mcp = MagicMock()
mcp._registered_resources = {}
def resource_decorator(
uri: str,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def wrapper(func: Callable[..., Any]) -> Callable[..., Any]:
mcp._registered_resources[uri] = func
return func
return wrapper
mcp.resource = resource_decorator
return mcp
@pytest.fixture
def mock_bridge(self) -> AsyncMock:
"""Create a mock FreeCAD bridge."""
return AsyncMock()
@pytest.fixture
def register_resources(
self, mock_mcp: MagicMock, mock_bridge: AsyncMock
) -> dict[str, Callable[..., Any]]:
"""Register resources and return the registered functions."""
from freecad_mcp.resources.freecad import register_resources
async def get_bridge() -> AsyncMock:
return mock_bridge
register_resources(mock_mcp, get_bridge)
return mock_mcp._registered_resources
@pytest.mark.asyncio
async def test_resource_version(
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
) -> None:
"""freecad://version should return version info."""
mock_bridge.get_freecad_version = AsyncMock(
return_value={
"version": "1.0.0",
"build_date": "2024-01-15",
"python_version": "3.11.6",
"gui_available": True,
}
)
resource_version = register_resources["freecad://version"]
result = await resource_version()
data = json.loads(result)
assert data["version"] == "1.0.0"
assert data["gui_available"] is True
mock_bridge.get_freecad_version.assert_called_once()
@pytest.mark.asyncio
async def test_resource_status_connected(
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
) -> None:
"""freecad://status should return connected status."""
mock_bridge.get_status = AsyncMock(
return_value=ConnectionStatus(
connected=True,
mode="xmlrpc",
freecad_version="1.0.0",
gui_available=True,
last_ping_ms=5.5,
error=None,
)
)
resource_status = register_resources["freecad://status"]
result = await resource_status()
data = json.loads(result)
assert data["connected"] is True
assert data["mode"] == "xmlrpc"
assert data["last_ping_ms"] == 5.5
assert data["error"] is None
@pytest.mark.asyncio
async def test_resource_status_disconnected(
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
) -> None:
"""freecad://status should return error when disconnected."""
mock_bridge.get_status = AsyncMock(
return_value=ConnectionStatus(
connected=False,
mode="xmlrpc",
error="Connection refused",
)
)
resource_status = register_resources["freecad://status"]
result = await resource_status()
data = json.loads(result)
assert data["connected"] is False
assert data["error"] == "Connection refused"
@pytest.mark.asyncio
async def test_resource_documents_empty(
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
) -> None:
"""freecad://documents should return empty list when no documents."""
mock_bridge.get_documents = AsyncMock(return_value=[])
resource_documents = register_resources["freecad://documents"]
result = await resource_documents()
data = json.loads(result)
assert data == []
@pytest.mark.asyncio
async def test_resource_documents_with_docs(
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
) -> None:
"""freecad://documents should return document list."""
mock_docs = [
DocumentInfo(
name="Doc1",
label="Document 1",
path="/tmp/doc1.FCStd",
objects=["Box", "Cylinder"],
is_modified=False,
active_object="Box",
),
DocumentInfo(
name="Doc2",
label="Document 2",
path=None,
objects=["Sphere"],
is_modified=True,
active_object=None,
),
]
mock_bridge.get_documents = AsyncMock(return_value=mock_docs)
resource_documents = register_resources["freecad://documents"]
result = await resource_documents()
data = json.loads(result)
assert len(data) == 2
assert data[0]["name"] == "Doc1"
assert data[0]["object_count"] == 2
assert data[1]["name"] == "Doc2"
assert data[1]["is_modified"] is True
@pytest.mark.asyncio
async def test_resource_document_found(
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
) -> None:
"""freecad://documents/{name} should return document info."""
mock_docs = [
DocumentInfo(
name="TestDoc",
label="Test Document",
path="/tmp/test.FCStd",
objects=["Part1", "Part2"],
is_modified=False,
active_object="Part1",
),
]
mock_bridge.get_documents = AsyncMock(return_value=mock_docs)
resource_document = register_resources["freecad://documents/{name}"]
result = await resource_document(name="TestDoc")
data = json.loads(result)
assert data["name"] == "TestDoc"
assert data["objects"] == ["Part1", "Part2"]
@pytest.mark.asyncio
async def test_resource_document_not_found(
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
) -> None:
"""freecad://documents/{name} should return error when not found."""
mock_bridge.get_documents = AsyncMock(return_value=[])
resource_document = register_resources["freecad://documents/{name}"]
result = await resource_document(name="NonExistent")
data = json.loads(result)
assert "error" in data
assert "not found" in data["error"]
@pytest.mark.asyncio
async def test_resource_document_objects(
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
) -> None:
"""freecad://documents/{name}/objects should return object list."""
mock_objects = [
ObjectInfo(
name="Box",
label="My Box",
type_id="Part::Box",
visibility=True,
children=[],
parents=[],
),
ObjectInfo(
name="Cylinder",
label="My Cylinder",
type_id="Part::Cylinder",
visibility=False,
children=[],
parents=[],
),
]
mock_bridge.get_objects = AsyncMock(return_value=mock_objects)
resource_objects = register_resources["freecad://documents/{name}/objects"]
result = await resource_objects(name="TestDoc")
data = json.loads(result)
assert len(data) == 2
assert data[0]["name"] == "Box"
assert data[0]["type_id"] == "Part::Box"
assert data[1]["visibility"] is False
@pytest.mark.asyncio
async def test_resource_object_details(
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
) -> None:
"""freecad://objects/{doc_name}/{obj_name} should return object details."""
mock_object = ObjectInfo(
name="Box",
label="My Box",
type_id="Part::Box",
properties={"Length": 10.0, "Width": 20.0, "Height": 30.0},
shape_info={
"shape_type": "Solid",
"volume": 6000.0,
"area": 2200.0,
"is_valid": True,
},
visibility=True,
children=[],
parents=[],
)
mock_bridge.get_object = AsyncMock(return_value=mock_object)
resource_object = register_resources["freecad://objects/{doc_name}/{obj_name}"]
result = await resource_object(doc_name="TestDoc", obj_name="Box")
data = json.loads(result)
assert data["name"] == "Box"
assert data["type_id"] == "Part::Box"
assert data["properties"]["Length"] == 10.0
assert data["shape_info"]["volume"] == 6000.0
@pytest.mark.asyncio
async def test_resource_active_document(
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
) -> None:
"""freecad://active-document should return active document."""
mock_doc = DocumentInfo(
name="ActiveDoc",
label="Active Document",
path="/tmp/active.FCStd",
objects=["Part1"],
is_modified=True,
active_object="Part1",
)
mock_bridge.get_active_document = AsyncMock(return_value=mock_doc)
resource_active = register_resources["freecad://active-document"]
result = await resource_active()
data = json.loads(result)
assert data["name"] == "ActiveDoc"
assert data["is_modified"] is True
@pytest.mark.asyncio
async def test_resource_active_document_none(
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
) -> None:
"""freecad://active-document should return null when no active document."""
mock_bridge.get_active_document = AsyncMock(return_value=None)
resource_active = register_resources["freecad://active-document"]
result = await resource_active()
data = json.loads(result)
# Implementation returns json.dumps(None) which deserializes to Python None
assert data is None
@pytest.mark.asyncio
async def test_resource_workbenches(
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
) -> None:
"""freecad://workbenches should return workbench list."""
mock_workbenches = [
WorkbenchInfo(
name="PartDesignWorkbench",
label="Part Design",
icon="",
is_active=True,
),
WorkbenchInfo(
name="SketcherWorkbench",
label="Sketcher",
icon="",
is_active=False,
),
]
mock_bridge.get_workbenches = AsyncMock(return_value=mock_workbenches)
resource_workbenches = register_resources["freecad://workbenches"]
result = await resource_workbenches()
data = json.loads(result)
assert len(data) == 2
assert data[0]["name"] == "PartDesignWorkbench"
assert data[0]["is_active"] is True
@pytest.mark.asyncio
async def test_resource_active_workbench(
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
) -> None:
"""freecad://workbenches/active should return active workbench."""
mock_workbenches = [
WorkbenchInfo(
name="PartDesignWorkbench",
label="Part Design",
icon="",
is_active=True,
),
WorkbenchInfo(
name="SketcherWorkbench",
label="Sketcher",
icon="",
is_active=False,
),
]
mock_bridge.get_workbenches = AsyncMock(return_value=mock_workbenches)
resource_active_wb = register_resources["freecad://workbenches/active"]
result = await resource_active_wb()
data = json.loads(result)
assert data["name"] == "PartDesignWorkbench"
assert data["label"] == "Part Design"
@pytest.mark.asyncio
async def test_resource_macros(
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
) -> None:
"""freecad://macros should return macro list."""
mock_macros = [
MacroInfo(
name="MultiExport",
path="/home/user/.local/share/FreeCAD/Macro/MultiExport.FCMacro",
description="Export to multiple formats",
is_system=False,
),
MacroInfo(
name="SystemMacro",
path="/usr/share/freecad/Macro/SystemMacro.FCMacro",
description="System macro",
is_system=True,
),
]
mock_bridge.get_macros = AsyncMock(return_value=mock_macros)
resource_macros = register_resources["freecad://macros"]
result = await resource_macros()
data = json.loads(result)
assert len(data) == 2
assert data[0]["name"] == "MultiExport"
assert data[0]["is_system"] is False
assert data[1]["is_system"] is True
@pytest.mark.asyncio
async def test_resource_console(
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
) -> None:
"""freecad://console should return console output."""
mock_bridge.get_console_output = AsyncMock(
return_value=[
"FreeCAD started",
"Document created",
"Box created",
]
)
resource_console = register_resources["freecad://console"]
result = await resource_console()
data = json.loads(result)
assert "lines" in data
assert len(data["lines"]) == 3
assert data["count"] == 3
@pytest.mark.asyncio
async def test_resource_capabilities(
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
) -> None:
"""freecad://capabilities should return server capabilities."""
resource_capabilities = register_resources["freecad://capabilities"]
result = await resource_capabilities()
data = json.loads(result)
# Should have tools section
assert "tools" in data
assert "execution" in data["tools"]
assert "documents" in data["tools"]
# Should have resources section - list of dicts with uri/description
assert "resources" in data
assert any("capabilities" in r.get("uri", "") for r in data["resources"])
# Should have prompts section
assert "prompts" in data
+290
View File
@@ -0,0 +1,290 @@
"""Tests for the main server module."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from freecad_mcp.config import FreecadMode
class TestGetInstanceId:
"""Tests for get_instance_id function."""
def test_returns_string(self):
"""Instance ID should be a string."""
from freecad_mcp.server import get_instance_id
instance_id = get_instance_id()
assert isinstance(instance_id, str)
def test_returns_uuid_format(self):
"""Instance ID should be a valid UUID format."""
from freecad_mcp.server import get_instance_id
instance_id = get_instance_id()
# UUID format: 8-4-4-4-12 hex characters
parts = instance_id.split("-")
assert len(parts) == 5
assert len(parts[0]) == 8
assert len(parts[1]) == 4
assert len(parts[2]) == 4
assert len(parts[3]) == 4
assert len(parts[4]) == 12
def test_consistent_across_calls(self):
"""Instance ID should be consistent within a process."""
from freecad_mcp.server import get_instance_id
id1 = get_instance_id()
id2 = get_instance_id()
assert id1 == id2
class TestGetBridge:
"""Tests for get_bridge function."""
@pytest.mark.asyncio
async def test_raises_when_not_initialized(self):
"""Should raise RuntimeError when bridge is not initialized."""
import freecad_mcp.server as server_module
# Save original bridge
original_bridge = server_module._bridge
try:
# Set bridge to None
server_module._bridge = None
with pytest.raises(RuntimeError, match="not initialized"):
await server_module.get_bridge()
finally:
# Restore original bridge
server_module._bridge = original_bridge
@pytest.mark.asyncio
async def test_returns_bridge_when_initialized(self):
"""Should return bridge when it's initialized."""
import freecad_mcp.server as server_module
# Save original bridge
original_bridge = server_module._bridge
try:
# Set up mock bridge
mock_bridge = MagicMock()
server_module._bridge = mock_bridge
bridge = await server_module.get_bridge()
assert bridge is mock_bridge
finally:
# Restore original bridge
server_module._bridge = original_bridge
class TestLifespan:
"""Tests for the lifespan context manager."""
@pytest.mark.asyncio
async def test_embedded_mode_initialization(self):
"""Should initialize embedded bridge in embedded mode."""
import freecad_mcp.server as server_module
mock_config = MagicMock()
mock_config.mode = FreecadMode.EMBEDDED
mock_config.freecad_path = None
mock_embedded_bridge = AsyncMock()
mock_embedded_bridge.get_freecad_version = AsyncMock(
return_value={"version": "1.0.0", "gui_available": False}
)
with (
patch.object(server_module, "get_config", return_value=mock_config),
patch(
"freecad_mcp.bridge.embedded.EmbeddedBridge",
return_value=mock_embedded_bridge,
) as mock_embedded_class,
):
mock_server = MagicMock()
async with server_module.lifespan(mock_server):
# Bridge should be initialized
mock_embedded_class.assert_called_once_with(freecad_path=None)
mock_embedded_bridge.connect.assert_called_once()
# After exiting, disconnect should be called
mock_embedded_bridge.disconnect.assert_called_once()
@pytest.mark.asyncio
async def test_xmlrpc_mode_initialization(self):
"""Should initialize XML-RPC bridge in xmlrpc mode."""
import freecad_mcp.server as server_module
mock_config = MagicMock()
mock_config.mode = FreecadMode.XMLRPC
mock_config.socket_host = "localhost"
mock_config.xmlrpc_port = 9875
mock_xmlrpc_bridge = AsyncMock()
mock_xmlrpc_bridge.get_freecad_version = AsyncMock(
return_value={"version": "1.0.0", "gui_available": True}
)
with (
patch.object(server_module, "get_config", return_value=mock_config),
patch(
"freecad_mcp.bridge.xmlrpc.XmlRpcBridge",
return_value=mock_xmlrpc_bridge,
) as mock_xmlrpc_class,
):
mock_server = MagicMock()
async with server_module.lifespan(mock_server):
mock_xmlrpc_class.assert_called_once_with(host="localhost", port=9875)
mock_xmlrpc_bridge.connect.assert_called_once()
mock_xmlrpc_bridge.disconnect.assert_called_once()
@pytest.mark.asyncio
async def test_socket_mode_initialization(self):
"""Should initialize socket bridge in socket mode."""
import freecad_mcp.server as server_module
mock_config = MagicMock()
mock_config.mode = FreecadMode.SOCKET
mock_config.socket_host = "localhost"
mock_config.socket_port = 9876
mock_socket_bridge = AsyncMock()
mock_socket_bridge.get_freecad_version = AsyncMock(
return_value={"version": "1.0.0", "gui_available": True}
)
with (
patch.object(server_module, "get_config", return_value=mock_config),
patch(
"freecad_mcp.bridge.socket.SocketBridge",
return_value=mock_socket_bridge,
) as mock_socket_class,
):
mock_server = MagicMock()
async with server_module.lifespan(mock_server):
mock_socket_class.assert_called_once_with(host="localhost", port=9876)
mock_socket_bridge.connect.assert_called_once()
mock_socket_bridge.disconnect.assert_called_once()
@pytest.mark.asyncio
async def test_version_fetch_failure_logs_warning(self):
"""Should log warning if version fetch fails."""
import freecad_mcp.server as server_module
mock_config = MagicMock()
mock_config.mode = FreecadMode.EMBEDDED
mock_config.freecad_path = None
mock_bridge = AsyncMock()
mock_bridge.get_freecad_version = AsyncMock(
side_effect=Exception("Connection failed")
)
with (
patch.object(server_module, "get_config", return_value=mock_config),
patch(
"freecad_mcp.bridge.embedded.EmbeddedBridge",
return_value=mock_bridge,
),
patch.object(server_module.logger, "warning") as mock_warning,
):
mock_server = MagicMock()
async with server_module.lifespan(mock_server):
# Warning should be logged
mock_warning.assert_called_once()
assert "Could not get FreeCAD version" in str(mock_warning.call_args)
class TestRegisterAllComponents:
"""Tests for register_all_components function."""
def test_registers_tools(self):
"""Should register all tool categories."""
from freecad_mcp.server import mcp
# The function is called at module load, but we can verify
# that the mcp instance exists and has tools registered
assert mcp is not None
assert mcp.name == "freecad-mcp"
class TestMain:
"""Tests for main function."""
def test_main_prints_instance_id(self):
"""Main should print instance ID on startup."""
import freecad_mcp.server as server_module
from freecad_mcp.config import TransportType
mock_config = MagicMock()
mock_config.log_level = "INFO"
mock_config.mode = FreecadMode.EMBEDDED
mock_config.transport = TransportType.STDIO
with (
patch.object(server_module, "get_config", return_value=mock_config),
patch.object(server_module.mcp, "run") as mock_run,
patch("builtins.print") as mock_print,
):
# Mock run to exit immediately
mock_run.return_value = None
server_module.main()
# Check that instance ID was printed
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)
def test_main_http_transport(self):
"""Main should start HTTP transport when configured."""
import freecad_mcp.server as server_module
from freecad_mcp.config import TransportType
mock_config = MagicMock()
mock_config.log_level = "INFO"
mock_config.mode = FreecadMode.EMBEDDED
mock_config.transport = TransportType.HTTP
mock_config.http_port = 8080
with (
patch.object(server_module, "get_config", return_value=mock_config),
patch.object(server_module.mcp, "run") as mock_run,
patch("builtins.print"),
):
server_module.main()
# Should call run with HTTP transport settings
mock_run.assert_called_once()
call_kwargs = mock_run.call_args.kwargs
assert call_kwargs.get("transport") == "streamable-http"
assert call_kwargs.get("port") == 8080
def test_main_stdio_transport(self):
"""Main should start stdio transport by default."""
import freecad_mcp.server as server_module
from freecad_mcp.config import TransportType
mock_config = MagicMock()
mock_config.log_level = "INFO"
mock_config.mode = FreecadMode.EMBEDDED
mock_config.transport = TransportType.STDIO
with (
patch.object(server_module, "get_config", return_value=mock_config),
patch.object(server_module.mcp, "run") as mock_run,
patch("builtins.print"),
):
server_module.main()
# Should call run without transport arguments (stdio is default)
mock_run.assert_called_once_with()
+283
View File
@@ -0,0 +1,283 @@
"""Tests for document tools module."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from freecad_mcp.bridge.base import DocumentInfo, ExecutionResult
class TestDocumentTools:
"""Tests for document management tools."""
@pytest.fixture
def mock_mcp(self):
"""Create a mock MCP server that captures tool registrations."""
mcp = MagicMock()
mcp._registered_tools = {}
def tool_decorator():
def wrapper(func):
mcp._registered_tools[func.__name__] = func
return func
return wrapper
mcp.tool = tool_decorator
return mcp
@pytest.fixture
def mock_bridge(self):
"""Create a mock FreeCAD bridge."""
return AsyncMock()
@pytest.fixture
def register_tools(self, mock_mcp, mock_bridge):
"""Register document tools and return the registered functions."""
from freecad_mcp.tools.documents import register_document_tools
async def get_bridge():
return mock_bridge
register_document_tools(mock_mcp, get_bridge)
return mock_mcp._registered_tools
@pytest.mark.asyncio
async def test_list_documents_empty(self, register_tools, mock_bridge):
"""list_documents should return empty list when no documents."""
mock_bridge.get_documents = AsyncMock(return_value=[])
list_documents = register_tools["list_documents"]
result = await list_documents()
assert result == []
mock_bridge.get_documents.assert_called_once()
@pytest.mark.asyncio
async def test_list_documents_with_docs(self, register_tools, mock_bridge):
"""list_documents should return document info."""
mock_docs = [
DocumentInfo(
name="Doc1",
label="Document 1",
path="/tmp/doc1.FCStd",
objects=["Box", "Cylinder"],
is_modified=False,
active_object="Box",
),
DocumentInfo(
name="Doc2",
label="Document 2",
path=None,
objects=["Sphere"],
is_modified=True,
active_object=None,
),
]
mock_bridge.get_documents = AsyncMock(return_value=mock_docs)
list_documents = register_tools["list_documents"]
result = await list_documents()
assert len(result) == 2
assert result[0]["name"] == "Doc1"
assert result[0]["object_count"] == 2
assert result[0]["is_modified"] is False
assert result[1]["name"] == "Doc2"
assert result[1]["object_count"] == 1
assert result[1]["is_modified"] is True
@pytest.mark.asyncio
async def test_get_active_document_none(self, register_tools, mock_bridge):
"""get_active_document should return None when no active document."""
mock_bridge.get_active_document = AsyncMock(return_value=None)
get_active_document = register_tools["get_active_document"]
result = await get_active_document()
assert result is None
mock_bridge.get_active_document.assert_called_once()
@pytest.mark.asyncio
async def test_get_active_document_returns_info(self, register_tools, mock_bridge):
"""get_active_document should return document info when available."""
mock_doc = DocumentInfo(
name="ActiveDoc",
label="Active Document",
path="/tmp/active.FCStd",
objects=["Part1", "Part2"],
is_modified=True,
active_object="Part1",
)
mock_bridge.get_active_document = AsyncMock(return_value=mock_doc)
get_active_document = register_tools["get_active_document"]
result = await get_active_document()
assert result["name"] == "ActiveDoc"
assert result["label"] == "Active Document"
assert result["objects"] == ["Part1", "Part2"]
assert result["is_modified"] is True
@pytest.mark.asyncio
async def test_create_document_default_name(self, register_tools, mock_bridge):
"""create_document should create with default name."""
mock_doc = DocumentInfo(
name="Unnamed",
label="Unnamed",
path=None,
objects=[],
is_modified=False,
)
mock_bridge.create_document = AsyncMock(return_value=mock_doc)
create_document = register_tools["create_document"]
result = await create_document()
assert result["name"] == "Unnamed"
mock_bridge.create_document.assert_called_once_with("Unnamed", None)
@pytest.mark.asyncio
async def test_create_document_with_name_and_label(
self, register_tools, mock_bridge
):
"""create_document should use provided name and label."""
mock_doc = DocumentInfo(
name="MyPart",
label="My Part Design",
path=None,
objects=[],
is_modified=False,
)
mock_bridge.create_document = AsyncMock(return_value=mock_doc)
create_document = register_tools["create_document"]
result = await create_document(name="MyPart", label="My Part Design")
assert result["name"] == "MyPart"
assert result["label"] == "My Part Design"
mock_bridge.create_document.assert_called_once_with("MyPart", "My Part Design")
@pytest.mark.asyncio
async def test_open_document(self, register_tools, mock_bridge):
"""open_document should open and return document info."""
mock_doc = DocumentInfo(
name="OpenedDoc",
label="Opened Document",
path="/tmp/test.FCStd",
objects=["Box", "Fillet"],
is_modified=False,
)
mock_bridge.open_document = AsyncMock(return_value=mock_doc)
open_document = register_tools["open_document"]
result = await open_document(path="/tmp/test.FCStd")
assert result["name"] == "OpenedDoc"
assert result["path"] == "/tmp/test.FCStd"
assert result["objects"] == ["Box", "Fillet"]
mock_bridge.open_document.assert_called_once_with("/tmp/test.FCStd")
@pytest.mark.asyncio
async def test_save_document_default(self, register_tools, mock_bridge):
"""save_document should save active document."""
mock_bridge.save_document = AsyncMock(return_value="/tmp/saved.FCStd")
save_document = register_tools["save_document"]
result = await save_document()
assert result["success"] is True
assert result["path"] == "/tmp/saved.FCStd"
mock_bridge.save_document.assert_called_once_with(None, None)
@pytest.mark.asyncio
async def test_save_document_with_path(self, register_tools, mock_bridge):
"""save_document should save to specified path."""
mock_bridge.save_document = AsyncMock(return_value="/new/path.FCStd")
save_document = register_tools["save_document"]
result = await save_document(doc_name="MyDoc", path="/new/path.FCStd")
assert result["success"] is True
assert result["path"] == "/new/path.FCStd"
mock_bridge.save_document.assert_called_once_with("MyDoc", "/new/path.FCStd")
@pytest.mark.asyncio
async def test_close_document_without_save(self, register_tools, mock_bridge):
"""close_document should close without saving by default."""
mock_bridge.close_document = AsyncMock()
close_document = register_tools["close_document"]
result = await close_document(doc_name="TestDoc")
assert result["success"] is True
assert result["saved"] is False
mock_bridge.close_document.assert_called_once_with("TestDoc")
@pytest.mark.asyncio
async def test_close_document_with_save(self, register_tools, mock_bridge):
"""close_document should save before closing when requested."""
mock_bridge.save_document = AsyncMock(return_value="/tmp/doc.FCStd")
mock_bridge.close_document = AsyncMock()
close_document = register_tools["close_document"]
result = await close_document(doc_name="TestDoc", save_changes=True)
assert result["success"] is True
assert result["saved"] is True
mock_bridge.save_document.assert_called_once_with("TestDoc")
mock_bridge.close_document.assert_called_once_with("TestDoc")
@pytest.mark.asyncio
async def test_close_document_save_failure(self, register_tools, mock_bridge):
"""close_document should still close even if save fails."""
mock_bridge.save_document = AsyncMock(side_effect=Exception("Save failed"))
mock_bridge.close_document = AsyncMock()
close_document = register_tools["close_document"]
result = await close_document(doc_name="TestDoc", save_changes=True)
assert result["success"] is True
assert result["saved"] is False
mock_bridge.close_document.assert_called_once()
@pytest.mark.asyncio
async def test_recompute_document_success(self, register_tools, mock_bridge):
"""recompute_document should return success on recompute."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result=True,
stdout="",
stderr="",
execution_time_ms=5.0,
)
)
recompute_document = register_tools["recompute_document"]
result = await recompute_document(doc_name="TestDoc")
assert result["success"] is True
assert result.get("error") is None
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_recompute_document_failure(self, register_tools, mock_bridge):
"""recompute_document should return error on failure."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=False,
result=None,
stdout="",
stderr="",
execution_time_ms=5.0,
error_type="ValueError",
error_traceback="No document found",
)
)
recompute_document = register_tools["recompute_document"]
result = await recompute_document(doc_name="NonExistent")
assert result["success"] is False
assert result["error"] == "No document found"
+275
View File
@@ -0,0 +1,275 @@
"""Tests for execution tools module."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from freecad_mcp.bridge.base import ConnectionStatus, ExecutionResult
class TestExecutionTools:
"""Tests for Python execution tools."""
@pytest.fixture
def mock_mcp(self):
"""Create a mock MCP server that captures tool registrations."""
mcp = MagicMock()
mcp._registered_tools = {}
def tool_decorator():
def wrapper(func):
mcp._registered_tools[func.__name__] = func
return func
return wrapper
mcp.tool = tool_decorator
return mcp
@pytest.fixture
def mock_bridge(self):
"""Create a mock FreeCAD bridge."""
return AsyncMock()
@pytest.fixture
def register_tools(self, mock_mcp, mock_bridge):
"""Register execution tools and return the registered functions."""
from freecad_mcp.tools.execution import register_execution_tools
async def get_bridge():
return mock_bridge
register_execution_tools(mock_mcp, get_bridge)
return mock_mcp._registered_tools
@pytest.mark.asyncio
async def test_execute_python_success(self, register_tools, mock_bridge):
"""execute_python should return success result."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"value": 42, "type": "int"},
stdout="",
stderr="",
execution_time_ms=10.5,
)
)
execute_python = register_tools["execute_python"]
result = await execute_python(code="_result_ = {'value': 42, 'type': 'int'}")
assert result["success"] is True
assert result["result"] == {"value": 42, "type": "int"}
assert result["execution_time_ms"] == 10.5
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_execute_python_with_timeout(self, register_tools, mock_bridge):
"""execute_python should pass timeout to bridge."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result=True,
stdout="",
stderr="",
execution_time_ms=5.0,
)
)
execute_python = register_tools["execute_python"]
await execute_python(code="_result_ = True", timeout_ms=60000)
mock_bridge.execute_python.assert_called_once()
call_args = mock_bridge.execute_python.call_args
assert call_args.kwargs.get("timeout_ms") == 60000 or call_args.args[1] == 60000
@pytest.mark.asyncio
async def test_execute_python_failure(self, register_tools, mock_bridge):
"""execute_python should return error on failure."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=False,
result=None,
stdout="",
stderr="NameError: name 'foo' is not defined",
execution_time_ms=2.0,
error_type="NameError",
error_traceback="Traceback...\nNameError: name 'foo' is not defined",
)
)
execute_python = register_tools["execute_python"]
result = await execute_python(code="foo")
assert result["success"] is False
assert result["error_type"] == "NameError"
assert "foo" in result["error_traceback"]
@pytest.mark.asyncio
async def test_execute_python_with_stdout(self, register_tools, mock_bridge):
"""execute_python should capture stdout."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result=None,
stdout="Hello, World!\n",
stderr="",
execution_time_ms=1.0,
)
)
execute_python = register_tools["execute_python"]
result = await execute_python(code="print('Hello, World!')")
assert result["success"] is True
assert result["stdout"] == "Hello, World!\n"
@pytest.mark.asyncio
async def test_get_freecad_version(self, register_tools, mock_bridge):
"""get_freecad_version should return version info."""
mock_bridge.get_freecad_version = AsyncMock(
return_value={
"version": "1.0.0",
"version_tuple": [1, 0, 0],
"build_date": "2024-01-15",
"python_version": "3.11.6",
"gui_available": True,
}
)
get_freecad_version = register_tools["get_freecad_version"]
result = await get_freecad_version()
assert result["version"] == "1.0.0"
assert result["gui_available"] is True
mock_bridge.get_freecad_version.assert_called_once()
@pytest.mark.asyncio
async def test_get_connection_status_connected(self, register_tools, mock_bridge):
"""get_connection_status should return connected status."""
mock_bridge.get_status = AsyncMock(
return_value=ConnectionStatus(
connected=True,
mode="xmlrpc",
freecad_version="1.0.0",
gui_available=True,
last_ping_ms=5.5,
error=None,
)
)
get_connection_status = register_tools["get_connection_status"]
result = await get_connection_status()
assert result["connected"] is True
assert result["mode"] == "xmlrpc"
assert result["last_ping_ms"] == 5.5
@pytest.mark.asyncio
async def test_get_connection_status_disconnected(
self, register_tools, mock_bridge
):
"""get_connection_status should return disconnected status with error."""
mock_bridge.get_status = AsyncMock(
return_value=ConnectionStatus(
connected=False,
mode="xmlrpc",
error="Connection refused",
)
)
get_connection_status = register_tools["get_connection_status"]
result = await get_connection_status()
assert result["connected"] is False
assert result["error"] == "Connection refused"
@pytest.mark.asyncio
async def test_get_console_output(self, register_tools, mock_bridge):
"""get_console_output should return console lines."""
mock_bridge.get_console_output = AsyncMock(
return_value=[
"FreeCAD started",
"Document created: TestDoc",
"Box created",
]
)
get_console_output = register_tools["get_console_output"]
result = await get_console_output()
# Returns a list directly, not a dict
assert result == [
"FreeCAD started",
"Document created: TestDoc",
"Box created",
]
mock_bridge.get_console_output.assert_called_once()
@pytest.mark.asyncio
async def test_get_console_output_with_lines_param(
self, register_tools, mock_bridge
):
"""get_console_output should pass lines parameter."""
mock_bridge.get_console_output = AsyncMock(return_value=["Line 1"])
get_console_output = register_tools["get_console_output"]
await get_console_output(lines=50)
mock_bridge.get_console_output.assert_called_once_with(50)
@pytest.mark.asyncio
async def test_get_mcp_server_environment(self, register_tools, mock_bridge):
"""get_mcp_server_environment should return environment info."""
mock_bridge.get_status = AsyncMock(
return_value=ConnectionStatus(
connected=True,
mode="xmlrpc",
freecad_version="1.0.0",
gui_available=True,
last_ping_ms=5.0,
error=None,
)
)
get_env = register_tools["get_mcp_server_environment"]
result = await get_env()
# Should have standard fields
assert "instance_id" in result
assert "hostname" in result
assert "os_name" in result
assert "python_version" in result
assert "in_docker" in result
# Should have freecad status
assert "freecad" in result
assert result["freecad"]["connected"] is True
assert result["freecad"]["mode"] == "xmlrpc"
assert result["freecad"]["is_headless"] is False
# Should have env vars
assert "env_vars" in result
mock_bridge.get_status.assert_called_once()
@pytest.mark.asyncio
async def test_get_mcp_server_environment_headless(
self, register_tools, mock_bridge
):
"""get_mcp_server_environment should detect headless mode."""
mock_bridge.get_status = AsyncMock(
return_value=ConnectionStatus(
connected=True,
mode="embedded",
freecad_version="1.0.0",
gui_available=False,
last_ping_ms=0.0,
error=None,
)
)
get_env = register_tools["get_mcp_server_environment"]
result = await get_env()
assert result["freecad"]["gui_available"] is False
assert result["freecad"]["is_headless"] is True
+304
View File
@@ -0,0 +1,304 @@
"""Tests for export/import tools module."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from freecad_mcp.bridge.base import ExecutionResult
class TestExportTools:
"""Tests for export/import tools."""
@pytest.fixture
def mock_mcp(self):
"""Create a mock MCP server that captures tool registrations."""
mcp = MagicMock()
mcp._registered_tools = {}
def tool_decorator():
def wrapper(func):
mcp._registered_tools[func.__name__] = func
return func
return wrapper
mcp.tool = tool_decorator
return mcp
@pytest.fixture
def mock_bridge(self):
"""Create a mock FreeCAD bridge."""
return AsyncMock()
@pytest.fixture
def register_tools(self, mock_mcp, mock_bridge):
"""Register export tools and return the registered functions."""
from freecad_mcp.tools.export import register_export_tools
async def get_bridge():
return mock_bridge
register_export_tools(mock_mcp, get_bridge)
return mock_mcp._registered_tools
@pytest.mark.asyncio
async def test_export_step(self, register_tools, mock_bridge):
"""export_step should export to STEP format via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"success": True,
"path": "/tmp/output.step",
"object_count": 2,
},
stdout="",
stderr="",
execution_time_ms=50.0,
)
)
export_step = register_tools["export_step"]
result = await export_step(
file_path="/tmp/output.step", object_names=["Box", "Cylinder"]
)
assert result["success"] is True
assert result["path"] == "/tmp/output.step"
assert result["object_count"] == 2
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_export_step_all_visible(self, register_tools, mock_bridge):
"""export_step should export all visible objects when no names given."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"success": True,
"path": "/tmp/output.step",
"object_count": 5,
},
stdout="",
stderr="",
execution_time_ms=75.0,
)
)
export_step = register_tools["export_step"]
result = await export_step(file_path="/tmp/output.step")
assert result["success"] is True
assert result["object_count"] == 5
@pytest.mark.asyncio
async def test_export_stl(self, register_tools, mock_bridge):
"""export_stl should export to STL format via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"success": True,
"path": "/tmp/output.stl",
"object_count": 1,
},
stdout="",
stderr="",
execution_time_ms=30.0,
)
)
export_stl = register_tools["export_stl"]
result = await export_stl(file_path="/tmp/output.stl", object_names=["Box"])
assert result["success"] is True
assert result["path"] == "/tmp/output.stl"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_export_stl_with_tolerance(self, register_tools, mock_bridge):
"""export_stl should accept mesh tolerance parameter."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"success": True,
"path": "/tmp/fine.stl",
"object_count": 1,
},
stdout="",
stderr="",
execution_time_ms=45.0,
)
)
export_stl = register_tools["export_stl"]
result = await export_stl(file_path="/tmp/fine.stl", mesh_tolerance=0.01)
assert result["success"] is True
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_export_3mf(self, register_tools, mock_bridge):
"""export_3mf should export to 3MF format via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"success": True,
"path": "/tmp/output.3mf",
"object_count": 1,
},
stdout="",
stderr="",
execution_time_ms=40.0,
)
)
export_3mf = register_tools["export_3mf"]
result = await export_3mf(file_path="/tmp/output.3mf")
assert result["success"] is True
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_export_obj(self, register_tools, mock_bridge):
"""export_obj should export to OBJ format via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"success": True,
"path": "/tmp/output.obj",
"object_count": 1,
},
stdout="",
stderr="",
execution_time_ms=35.0,
)
)
export_obj = register_tools["export_obj"]
result = await export_obj(file_path="/tmp/output.obj")
assert result["success"] is True
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_export_iges(self, register_tools, mock_bridge):
"""export_iges should export to IGES format via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"success": True,
"path": "/tmp/output.iges",
"object_count": 1,
},
stdout="",
stderr="",
execution_time_ms=55.0,
)
)
export_iges = register_tools["export_iges"]
result = await export_iges(file_path="/tmp/output.iges")
assert result["success"] is True
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_import_step(self, register_tools, mock_bridge):
"""import_step should import STEP files via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"success": True,
"document": "Imported",
"objects": ["Part", "Assembly"],
},
stdout="",
stderr="",
execution_time_ms=100.0,
)
)
import_step = register_tools["import_step"]
result = await import_step(file_path="/tmp/input.step")
assert result["success"] is True
assert result["document"] == "Imported"
assert len(result["objects"]) == 2
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_import_stl(self, register_tools, mock_bridge):
"""import_stl should import STL files via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"success": True,
"document": "Mesh",
"object": "Mesh001",
},
stdout="",
stderr="",
execution_time_ms=80.0,
)
)
import_stl = register_tools["import_stl"]
result = await import_stl(file_path="/tmp/input.stl")
assert result["success"] is True
assert result["object"] == "Mesh001"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_export_step_failure(self, register_tools, mock_bridge):
"""export_step should raise ValueError on failure."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=False,
result=None,
stdout="",
stderr="FileNotFoundError: Directory does not exist",
execution_time_ms=5.0,
error_type="FileNotFoundError",
error_traceback="Traceback: FileNotFoundError: Directory does not exist",
)
)
export_step = register_tools["export_step"]
with pytest.raises(ValueError) as exc_info:
await export_step(file_path="/nonexistent/output.step")
assert "FileNotFoundError" in str(exc_info.value) or "Traceback" in str(
exc_info.value
)
@pytest.mark.asyncio
async def test_import_step_into_document(self, register_tools, mock_bridge):
"""import_step should import into specified document."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"success": True,
"document": "MyDoc",
"objects": ["ImportedPart"],
},
stdout="",
stderr="",
execution_time_ms=90.0,
)
)
import_step = register_tools["import_step"]
result = await import_step(file_path="/tmp/part.step", doc_name="MyDoc")
assert result["success"] is True
assert result["document"] == "MyDoc"
+390
View File
@@ -0,0 +1,390 @@
"""Tests for macro tools module."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from freecad_mcp.bridge.base import ExecutionResult, MacroInfo
class TestMacroTools:
"""Tests for macro management tools."""
@pytest.fixture
def mock_mcp(self):
"""Create a mock MCP server that captures tool registrations."""
mcp = MagicMock()
mcp._registered_tools = {}
def tool_decorator():
def wrapper(func):
mcp._registered_tools[func.__name__] = func
return func
return wrapper
mcp.tool = tool_decorator
return mcp
@pytest.fixture
def mock_bridge(self):
"""Create a mock FreeCAD bridge."""
return AsyncMock()
@pytest.fixture
def register_tools(self, mock_mcp, mock_bridge):
"""Register macro tools and return the registered functions."""
from freecad_mcp.tools.macros import register_macro_tools
async def get_bridge():
return mock_bridge
register_macro_tools(mock_mcp, get_bridge)
return mock_mcp._registered_tools
@pytest.mark.asyncio
async def test_list_macros_empty(self, register_tools, mock_bridge):
"""list_macros should return empty list when no macros."""
mock_bridge.get_macros = AsyncMock(return_value=[])
list_macros = register_tools["list_macros"]
result = await list_macros()
assert result == []
mock_bridge.get_macros.assert_called_once()
@pytest.mark.asyncio
async def test_list_macros_with_macros(self, register_tools, mock_bridge):
"""list_macros should return macro info."""
mock_macros = [
MacroInfo(
name="MultiExport",
path="/home/user/.FreeCAD/Macro/MultiExport.FCMacro",
description="Export to multiple formats",
is_system=False,
),
MacroInfo(
name="SystemMacro",
path="/usr/share/freecad/Macro/SystemMacro.FCMacro",
description="System macro",
is_system=True,
),
]
mock_bridge.get_macros = AsyncMock(return_value=mock_macros)
list_macros = register_tools["list_macros"]
result = await list_macros()
assert len(result) == 2
assert result[0]["name"] == "MultiExport"
assert result[0]["is_system"] is False
assert result[1]["name"] == "SystemMacro"
assert result[1]["is_system"] is True
@pytest.mark.asyncio
async def test_run_macro_success(self, register_tools, mock_bridge):
"""run_macro should execute a macro and return results."""
# run_macro calls bridge.run_macro which returns ExecutionResult
mock_bridge.run_macro = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"exported_count": 3},
stdout="Exported 3 objects\n",
stderr="",
execution_time_ms=150.0,
)
)
run_macro = register_tools["run_macro"]
result = await run_macro(macro_name="MultiExport")
assert result["success"] is True
assert result["stdout"] == "Exported 3 objects\n"
mock_bridge.run_macro.assert_called_once_with("MultiExport", None)
@pytest.mark.asyncio
async def test_run_macro_with_args(self, register_tools, mock_bridge):
"""run_macro should pass arguments to macro."""
mock_bridge.run_macro = AsyncMock(
return_value=ExecutionResult(
success=True,
result=None,
stdout="",
stderr="",
execution_time_ms=50.0,
)
)
run_macro = register_tools["run_macro"]
args = {"output_dir": "/tmp", "format": "step"}
result = await run_macro(macro_name="CustomMacro", args=args)
assert result["success"] is True
mock_bridge.run_macro.assert_called_once_with("CustomMacro", args)
@pytest.mark.asyncio
async def test_run_macro_failure(self, register_tools, mock_bridge):
"""run_macro should return error info on failure."""
mock_bridge.run_macro = AsyncMock(
return_value=ExecutionResult(
success=False,
result=None,
stdout="",
stderr="NameError: name 'undefined_var' is not defined",
execution_time_ms=10.0,
error_type="NameError",
error_traceback="Traceback...",
)
)
run_macro = register_tools["run_macro"]
result = await run_macro(macro_name="BrokenMacro")
assert result["success"] is False
assert result["error_type"] == "NameError"
@pytest.mark.asyncio
async def test_create_macro(self, register_tools, mock_bridge):
"""create_macro should create a new macro file via bridge.create_macro."""
# create_macro calls bridge.create_macro which returns MacroInfo
mock_macro = MacroInfo(
name="MyMacro",
path="/home/user/.FreeCAD/Macro/MyMacro.FCMacro",
description="My custom macro",
is_system=False,
)
mock_bridge.create_macro = AsyncMock(return_value=mock_macro)
create_macro = register_tools["create_macro"]
result = await create_macro(
name="MyMacro",
code="FreeCAD.Console.PrintMessage('Hello')",
description="My custom macro",
)
assert result["name"] == "MyMacro"
assert result["path"] == "/home/user/.FreeCAD/Macro/MyMacro.FCMacro"
assert result["description"] == "My custom macro"
mock_bridge.create_macro.assert_called_once_with(
"MyMacro", "FreeCAD.Console.PrintMessage('Hello')", "My custom macro"
)
@pytest.mark.asyncio
async def test_read_macro(self, register_tools, mock_bridge):
"""read_macro should return macro source code via execute_python."""
# read_macro uses execute_python to read file contents
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"name": "MyMacro",
"code": "import FreeCAD\nFreeCAD.Console.PrintMessage('Hello')",
"path": "/home/user/.FreeCAD/Macro/MyMacro.FCMacro",
},
stdout="",
stderr="",
execution_time_ms=10.0,
)
)
read_macro = register_tools["read_macro"]
result = await read_macro(macro_name="MyMacro")
assert result["name"] == "MyMacro"
assert "FreeCAD" in result["code"]
assert result["path"] == "/home/user/.FreeCAD/Macro/MyMacro.FCMacro"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_read_macro_not_found(self, register_tools, mock_bridge):
"""read_macro should raise error when macro not found."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=False,
result=None,
stdout="",
stderr="FileNotFoundError: Macro not found: NonExistent",
execution_time_ms=5.0,
error_type="FileNotFoundError",
error_traceback="Traceback...\nFileNotFoundError: Macro not found: NonExistent",
)
)
read_macro = register_tools["read_macro"]
with pytest.raises(ValueError) as exc_info:
await read_macro(macro_name="NonExistent")
assert "NonExistent" in str(exc_info.value) or "Traceback" in str(
exc_info.value
)
@pytest.mark.asyncio
async def test_delete_macro(self, register_tools, mock_bridge):
"""delete_macro should delete a user macro via execute_python."""
# delete_macro uses execute_python to delete file
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"success": True,
"path": "/home/user/.FreeCAD/Macro/OldMacro.FCMacro",
},
stdout="",
stderr="",
execution_time_ms=8.0,
)
)
delete_macro = register_tools["delete_macro"]
result = await delete_macro(macro_name="OldMacro")
assert result["success"] is True
assert result["path"] == "/home/user/.FreeCAD/Macro/OldMacro.FCMacro"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_delete_macro_not_found(self, register_tools, mock_bridge):
"""delete_macro should raise error when macro not found."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=False,
result=None,
stdout="",
stderr="FileNotFoundError: User macro not found: NonExistent",
execution_time_ms=5.0,
error_type="FileNotFoundError",
error_traceback="Traceback...\nFileNotFoundError: User macro not found: NonExistent",
)
)
delete_macro = register_tools["delete_macro"]
with pytest.raises(ValueError) as exc_info:
await delete_macro(macro_name="NonExistent")
assert "NonExistent" in str(exc_info.value) or "Traceback" in str(
exc_info.value
)
@pytest.mark.asyncio
async def test_create_macro_from_template_basic(self, register_tools, mock_bridge):
"""create_macro_from_template should create from basic template."""
# Uses bridge.create_macro with template code
mock_macro = MacroInfo(
name="NewMacro",
path="/home/user/.FreeCAD/Macro/NewMacro.FCMacro",
description="",
is_system=False,
)
mock_bridge.create_macro = AsyncMock(return_value=mock_macro)
create_from_template = register_tools["create_macro_from_template"]
result = await create_from_template(name="NewMacro", template="basic")
assert result["name"] == "NewMacro"
assert result["template"] == "basic"
mock_bridge.create_macro.assert_called_once()
# Verify basic template code was used
call_args = mock_bridge.create_macro.call_args
code_arg = call_args[0][1] # Second positional arg is code
assert "FreeCAD.ActiveDocument" in code_arg
@pytest.mark.asyncio
async def test_create_macro_from_template_part(self, register_tools, mock_bridge):
"""create_macro_from_template should create from part template."""
mock_macro = MacroInfo(
name="PartMacro",
path="/home/user/.FreeCAD/Macro/PartMacro.FCMacro",
description="Part operations",
is_system=False,
)
mock_bridge.create_macro = AsyncMock(return_value=mock_macro)
create_from_template = register_tools["create_macro_from_template"]
result = await create_from_template(
name="PartMacro", template="part", description="Part operations"
)
assert result["name"] == "PartMacro"
assert result["template"] == "part"
# Verify part template code was used
call_args = mock_bridge.create_macro.call_args
code_arg = call_args[0][1]
assert "import Part" in code_arg
@pytest.mark.asyncio
async def test_create_macro_from_template_sketch(self, register_tools, mock_bridge):
"""create_macro_from_template should create from sketch template."""
mock_macro = MacroInfo(
name="SketchMacro",
path="/home/user/.FreeCAD/Macro/SketchMacro.FCMacro",
description="",
is_system=False,
)
mock_bridge.create_macro = AsyncMock(return_value=mock_macro)
create_from_template = register_tools["create_macro_from_template"]
result = await create_from_template(name="SketchMacro", template="sketch")
assert result["name"] == "SketchMacro"
assert result["template"] == "sketch"
# Verify sketch template code was used
call_args = mock_bridge.create_macro.call_args
code_arg = call_args[0][1]
assert "Sketcher" in code_arg
@pytest.mark.asyncio
async def test_create_macro_from_template_gui(self, register_tools, mock_bridge):
"""create_macro_from_template should create from gui template."""
mock_macro = MacroInfo(
name="GuiMacro",
path="/home/user/.FreeCAD/Macro/GuiMacro.FCMacro",
description="",
is_system=False,
)
mock_bridge.create_macro = AsyncMock(return_value=mock_macro)
create_from_template = register_tools["create_macro_from_template"]
result = await create_from_template(name="GuiMacro", template="gui")
assert result["name"] == "GuiMacro"
assert result["template"] == "gui"
# Verify gui template code was used
call_args = mock_bridge.create_macro.call_args
code_arg = call_args[0][1]
assert "QtWidgets" in code_arg or "PySide" in code_arg
@pytest.mark.asyncio
async def test_create_macro_from_template_selection(
self, register_tools, mock_bridge
):
"""create_macro_from_template should create from selection template."""
mock_macro = MacroInfo(
name="SelectionMacro",
path="/home/user/.FreeCAD/Macro/SelectionMacro.FCMacro",
description="",
is_system=False,
)
mock_bridge.create_macro = AsyncMock(return_value=mock_macro)
create_from_template = register_tools["create_macro_from_template"]
result = await create_from_template(name="SelectionMacro", template="selection")
assert result["name"] == "SelectionMacro"
assert result["template"] == "selection"
# Verify selection template code was used
call_args = mock_bridge.create_macro.call_args
code_arg = call_args[0][1]
assert "Selection" in code_arg
@pytest.mark.asyncio
async def test_create_macro_from_template_invalid(
self, register_tools, mock_bridge
):
"""create_macro_from_template should raise error for invalid template."""
create_from_template = register_tools["create_macro_from_template"]
with pytest.raises(ValueError) as exc_info:
await create_from_template(name="BadMacro", template="invalid_template")
assert "Unknown template" in str(exc_info.value)
assert "invalid_template" in str(exc_info.value)
+525
View File
@@ -0,0 +1,525 @@
"""Tests for object tools module."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from freecad_mcp.bridge.base import ExecutionResult, ObjectInfo
class TestObjectTools:
"""Tests for object management tools."""
@pytest.fixture
def mock_mcp(self):
"""Create a mock MCP server that captures tool registrations."""
mcp = MagicMock()
mcp._registered_tools = {}
def tool_decorator():
def wrapper(func):
mcp._registered_tools[func.__name__] = func
return func
return wrapper
mcp.tool = tool_decorator
return mcp
@pytest.fixture
def mock_bridge(self):
"""Create a mock FreeCAD bridge."""
return AsyncMock()
@pytest.fixture
def register_tools(self, mock_mcp, mock_bridge):
"""Register object tools and return the registered functions."""
from freecad_mcp.tools.objects import register_object_tools
async def get_bridge():
return mock_bridge
register_object_tools(mock_mcp, get_bridge)
return mock_mcp._registered_tools
@pytest.mark.asyncio
async def test_list_objects_empty(self, register_tools, mock_bridge):
"""list_objects should return empty list when no objects."""
mock_bridge.get_objects = AsyncMock(return_value=[])
list_objects = register_tools["list_objects"]
result = await list_objects()
assert result == []
mock_bridge.get_objects.assert_called_once_with(None)
@pytest.mark.asyncio
async def test_list_objects_with_objects(self, register_tools, mock_bridge):
"""list_objects should return object info."""
mock_objects = [
ObjectInfo(
name="Box",
label="My Box",
type_id="Part::Box",
visibility=True,
children=[],
parents=[],
),
ObjectInfo(
name="Cylinder",
label="My Cylinder",
type_id="Part::Cylinder",
visibility=False,
children=[],
parents=[],
),
]
mock_bridge.get_objects = AsyncMock(return_value=mock_objects)
list_objects = register_tools["list_objects"]
result = await list_objects(doc_name="TestDoc")
assert len(result) == 2
assert result[0]["name"] == "Box"
assert result[0]["type_id"] == "Part::Box"
assert result[0]["visibility"] is True
assert result[1]["name"] == "Cylinder"
assert result[1]["visibility"] is False
mock_bridge.get_objects.assert_called_once_with("TestDoc")
@pytest.mark.asyncio
async def test_inspect_object(self, register_tools, mock_bridge):
"""inspect_object should return detailed object info."""
mock_object = ObjectInfo(
name="Box",
label="My Box",
type_id="Part::Box",
properties={"Length": 10.0, "Width": 20.0, "Height": 30.0},
shape_info={
"shape_type": "Solid",
"volume": 6000.0,
"area": 2200.0,
"is_valid": True,
},
visibility=True,
children=["Fillet001"],
parents=[],
)
mock_bridge.get_object = AsyncMock(return_value=mock_object)
inspect_object = register_tools["inspect_object"]
result = await inspect_object(object_name="Box")
assert result["name"] == "Box"
assert result["type_id"] == "Part::Box"
assert result["properties"]["Length"] == 10.0
assert result["shape_info"]["volume"] == 6000.0
assert result["children"] == ["Fillet001"]
mock_bridge.get_object.assert_called_once_with("Box", None)
@pytest.mark.asyncio
async def test_inspect_object_without_properties(self, register_tools, mock_bridge):
"""inspect_object should exclude properties when not requested."""
mock_object = ObjectInfo(
name="Box",
label="My Box",
type_id="Part::Box",
properties={"Length": 10.0},
shape_info=None,
visibility=True,
children=[],
parents=[],
)
mock_bridge.get_object = AsyncMock(return_value=mock_object)
inspect_object = register_tools["inspect_object"]
result = await inspect_object(
object_name="Box", include_properties=False, include_shape=False
)
assert result["name"] == "Box"
assert "properties" not in result
assert "shape_info" not in result
@pytest.mark.asyncio
async def test_create_object(self, register_tools, mock_bridge):
"""create_object should create and return object info."""
mock_object = ObjectInfo(
name="Box",
label="Box",
type_id="Part::Box",
visibility=True,
children=[],
parents=[],
)
mock_bridge.create_object = AsyncMock(return_value=mock_object)
create_object = register_tools["create_object"]
result = await create_object(type_id="Part::Box", name="Box")
assert result["name"] == "Box"
assert result["type_id"] == "Part::Box"
mock_bridge.create_object.assert_called_once()
@pytest.mark.asyncio
async def test_edit_object(self, register_tools, mock_bridge):
"""edit_object should update object properties."""
mock_object = ObjectInfo(
name="Box",
label="Box",
type_id="Part::Box",
properties={"Length": 20.0, "Width": 10.0},
visibility=True,
children=[],
parents=[],
)
mock_bridge.edit_object = AsyncMock(return_value=mock_object)
edit_object = register_tools["edit_object"]
result = await edit_object(object_name="Box", properties={"Length": 20.0})
assert result["name"] == "Box"
mock_bridge.edit_object.assert_called_once_with("Box", {"Length": 20.0}, None)
@pytest.mark.asyncio
async def test_delete_object(self, register_tools, mock_bridge):
"""delete_object should delete and return success."""
mock_bridge.delete_object = AsyncMock(return_value=True)
delete_object = register_tools["delete_object"]
result = await delete_object(object_name="Box")
assert result["success"] is True
mock_bridge.delete_object.assert_called_once_with("Box", None)
@pytest.mark.asyncio
async def test_create_box(self, register_tools, mock_bridge):
"""create_box should create a box primitive via create_object."""
mock_object = ObjectInfo(
name="Box",
label="Box",
type_id="Part::Box",
visibility=True,
children=[],
parents=[],
)
mock_bridge.create_object = AsyncMock(return_value=mock_object)
create_box = register_tools["create_box"]
result = await create_box(length=20.0, width=10.0, height=5.0)
assert result["name"] == "Box"
assert result["volume"] == 20.0 * 10.0 * 5.0
mock_bridge.create_object.assert_called_once()
@pytest.mark.asyncio
async def test_create_cylinder(self, register_tools, mock_bridge):
"""create_cylinder should create a cylinder primitive via create_object."""
mock_object = ObjectInfo(
name="Cylinder",
label="Cylinder",
type_id="Part::Cylinder",
visibility=True,
children=[],
parents=[],
)
mock_bridge.create_object = AsyncMock(return_value=mock_object)
create_cylinder = register_tools["create_cylinder"]
result = await create_cylinder(radius=5.0, height=20.0)
assert result["name"] == "Cylinder"
mock_bridge.create_object.assert_called_once()
@pytest.mark.asyncio
async def test_create_sphere(self, register_tools, mock_bridge):
"""create_sphere should create a sphere primitive via create_object."""
mock_object = ObjectInfo(
name="Sphere",
label="Sphere",
type_id="Part::Sphere",
visibility=True,
children=[],
parents=[],
)
mock_bridge.create_object = AsyncMock(return_value=mock_object)
create_sphere = register_tools["create_sphere"]
result = await create_sphere(radius=10.0)
assert result["name"] == "Sphere"
mock_bridge.create_object.assert_called_once()
@pytest.mark.asyncio
async def test_create_cone(self, register_tools, mock_bridge):
"""create_cone should create a cone primitive via create_object."""
mock_object = ObjectInfo(
name="Cone",
label="Cone",
type_id="Part::Cone",
visibility=True,
children=[],
parents=[],
)
mock_bridge.create_object = AsyncMock(return_value=mock_object)
create_cone = register_tools["create_cone"]
result = await create_cone(radius1=10.0, radius2=0.0, height=20.0)
assert result["name"] == "Cone"
mock_bridge.create_object.assert_called_once()
@pytest.mark.asyncio
async def test_create_torus(self, register_tools, mock_bridge):
"""create_torus should create a torus primitive via create_object."""
mock_object = ObjectInfo(
name="Torus",
label="Torus",
type_id="Part::Torus",
visibility=True,
children=[],
parents=[],
)
mock_bridge.create_object = AsyncMock(return_value=mock_object)
create_torus = register_tools["create_torus"]
result = await create_torus(radius1=20.0, radius2=5.0)
assert result["name"] == "Torus"
mock_bridge.create_object.assert_called_once()
@pytest.mark.asyncio
async def test_create_wedge(self, register_tools, mock_bridge):
"""create_wedge should create a wedge primitive via create_object."""
mock_object = ObjectInfo(
name="Wedge",
label="Wedge",
type_id="Part::Wedge",
visibility=True,
children=[],
parents=[],
)
mock_bridge.create_object = AsyncMock(return_value=mock_object)
create_wedge = register_tools["create_wedge"]
result = await create_wedge()
assert result["name"] == "Wedge"
mock_bridge.create_object.assert_called_once()
@pytest.mark.asyncio
async def test_create_helix(self, register_tools, mock_bridge):
"""create_helix should create a helix primitive via create_object."""
mock_object = ObjectInfo(
name="Helix",
label="Helix",
type_id="Part::Helix",
visibility=True,
children=[],
parents=[],
)
mock_bridge.create_object = AsyncMock(return_value=mock_object)
create_helix = register_tools["create_helix"]
result = await create_helix(pitch=5.0, height=20.0)
assert result["name"] == "Helix"
mock_bridge.create_object.assert_called_once()
# Tests for execute_python based tools
@pytest.mark.asyncio
async def test_boolean_operation_fuse(self, register_tools, mock_bridge):
"""boolean_operation should perform union operation via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"name": "Fusion",
"label": "Fusion",
"type_id": "Part::MultiFuse",
},
stdout="",
stderr="",
execution_time_ms=10.0,
)
)
boolean_operation = register_tools["boolean_operation"]
result = await boolean_operation(
operation="fuse", object1_name="Box", object2_name="Cylinder"
)
assert result["name"] == "Fusion"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_set_placement(self, register_tools, mock_bridge):
"""set_placement should set position and rotation via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"position": [10.0, 20.0, 30.0], "rotation": [0.0, 0.0, 45.0]},
stdout="",
stderr="",
execution_time_ms=5.0,
)
)
set_placement = register_tools["set_placement"]
result = await set_placement(object_name="Box", position=[10.0, 20.0, 30.0])
assert result["position"] == [10.0, 20.0, 30.0]
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_scale_object(self, register_tools, mock_bridge):
"""scale_object should scale an object via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"name": "ScaledBox",
"label": "ScaledBox",
"type_id": "Part::Feature",
},
stdout="",
stderr="",
execution_time_ms=15.0,
)
)
scale_object = register_tools["scale_object"]
result = await scale_object(object_name="Box", scale=2.0)
assert result["name"] == "ScaledBox"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_rotate_object(self, register_tools, mock_bridge):
"""rotate_object should rotate an object via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"position": [0.0, 0.0, 0.0], "rotation": [0.0, 0.0, 45.0]},
stdout="",
stderr="",
execution_time_ms=5.0,
)
)
rotate_object = register_tools["rotate_object"]
result = await rotate_object(
object_name="Box", axis=[0.0, 0.0, 1.0], angle=45.0
)
assert result["rotation"] == [0.0, 0.0, 45.0]
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_copy_object(self, register_tools, mock_bridge):
"""copy_object should create a copy via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"name": "Box001", "label": "Box001", "type_id": "Part::Box"},
stdout="",
stderr="",
execution_time_ms=10.0,
)
)
copy_object = register_tools["copy_object"]
result = await copy_object(object_name="Box")
assert result["name"] == "Box001"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_mirror_object(self, register_tools, mock_bridge):
"""mirror_object should mirror across a plane via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"name": "MirroredBox",
"label": "MirroredBox",
"type_id": "Part::Feature",
},
stdout="",
stderr="",
execution_time_ms=15.0,
)
)
mirror_object = register_tools["mirror_object"]
result = await mirror_object(object_name="Box", plane="XY")
assert result["name"] == "MirroredBox"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_get_selection(self, register_tools, mock_bridge):
"""get_selection should return selected objects via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result=[
{
"name": "Box",
"label": "Box",
"type_id": "Part::Box",
"sub_elements": ["Face1"],
}
],
stdout="",
stderr="",
execution_time_ms=5.0,
)
)
get_selection = register_tools["get_selection"]
result = await get_selection()
assert len(result) == 1
assert result[0]["name"] == "Box"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_set_selection(self, register_tools, mock_bridge):
"""set_selection should select objects via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"success": True, "selected_count": 2},
stdout="",
stderr="",
execution_time_ms=5.0,
)
)
set_selection = register_tools["set_selection"]
result = await set_selection(object_names=["Box", "Cylinder"])
assert result["success"] is True
assert result["selected_count"] == 2
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_clear_selection(self, register_tools, mock_bridge):
"""clear_selection should clear selections via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"success": True},
stdout="",
stderr="",
execution_time_ms=5.0,
)
)
clear_selection = register_tools["clear_selection"]
result = await clear_selection()
assert result["success"] is True
mock_bridge.execute_python.assert_called_once()
+465
View File
@@ -0,0 +1,465 @@
"""Tests for PartDesign tools module."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from freecad_mcp.bridge.base import ExecutionResult, ObjectInfo
class TestPartDesignTools:
"""Tests for PartDesign tools."""
@pytest.fixture
def mock_mcp(self):
"""Create a mock MCP server that captures tool registrations."""
mcp = MagicMock()
mcp._registered_tools = {}
def tool_decorator():
def wrapper(func):
mcp._registered_tools[func.__name__] = func
return func
return wrapper
mcp.tool = tool_decorator
return mcp
@pytest.fixture
def mock_bridge(self):
"""Create a mock FreeCAD bridge."""
return AsyncMock()
@pytest.fixture
def register_tools(self, mock_mcp, mock_bridge):
"""Register PartDesign tools and return the registered functions."""
from freecad_mcp.tools.partdesign import register_partdesign_tools
async def get_bridge():
return mock_bridge
register_partdesign_tools(mock_mcp, get_bridge)
return mock_mcp._registered_tools
@pytest.mark.asyncio
async def test_create_partdesign_body(self, register_tools, mock_bridge):
"""create_partdesign_body should create a body container via create_object."""
mock_object = ObjectInfo(
name="Body",
label="Body",
type_id="PartDesign::Body",
visibility=True,
children=[],
parents=[],
)
mock_bridge.create_object = AsyncMock(return_value=mock_object)
create_body = register_tools["create_partdesign_body"]
result = await create_body(name="Body")
assert result["name"] == "Body"
assert result["type_id"] == "PartDesign::Body"
mock_bridge.create_object.assert_called_once()
@pytest.mark.asyncio
async def test_create_sketch(self, register_tools, mock_bridge):
"""create_sketch should create a sketch via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"name": "Sketch",
"label": "Sketch",
"type_id": "Sketcher::SketchObject",
"support": "XY_Plane",
},
stdout="",
stderr="",
execution_time_ms=10.0,
)
)
create_sketch = register_tools["create_sketch"]
result = await create_sketch(body_name="Body", plane="XY_Plane")
assert result["name"] == "Sketch"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_add_sketch_rectangle(self, register_tools, mock_bridge):
"""add_sketch_rectangle should add a rectangle via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"constraint_count": 8, "geometry_count": 4},
stdout="",
stderr="",
execution_time_ms=10.0,
)
)
add_rectangle = register_tools["add_sketch_rectangle"]
result = await add_rectangle(
sketch_name="Sketch", x=-10, y=-10, width=20, height=20
)
assert result["constraint_count"] == 8
assert result["geometry_count"] == 4
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_add_sketch_circle(self, register_tools, mock_bridge):
"""add_sketch_circle should add a circle via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"geometry_index": 0, "geometry_count": 1},
stdout="",
stderr="",
execution_time_ms=10.0,
)
)
add_circle = register_tools["add_sketch_circle"]
result = await add_circle(
sketch_name="Sketch", center_x=0, center_y=0, radius=10
)
assert result["geometry_index"] == 0
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_add_sketch_line(self, register_tools, mock_bridge):
"""add_sketch_line should add a line via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"geometry_index": 0, "geometry_count": 1},
stdout="",
stderr="",
execution_time_ms=10.0,
)
)
add_line = register_tools["add_sketch_line"]
result = await add_line(sketch_name="Sketch", x1=0, y1=0, x2=10, y2=10)
assert result["geometry_index"] == 0
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_add_sketch_arc(self, register_tools, mock_bridge):
"""add_sketch_arc should add an arc via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"geometry_index": 0, "geometry_count": 1},
stdout="",
stderr="",
execution_time_ms=10.0,
)
)
add_arc = register_tools["add_sketch_arc"]
result = await add_arc(
sketch_name="Sketch",
center_x=0,
center_y=0,
radius=10,
start_angle=0,
end_angle=90,
)
assert result["geometry_index"] == 0
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_add_sketch_point(self, register_tools, mock_bridge):
"""add_sketch_point should add a point via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"geometry_index": 0, "geometry_count": 1},
stdout="",
stderr="",
execution_time_ms=10.0,
)
)
add_point = register_tools["add_sketch_point"]
result = await add_point(sketch_name="Sketch", x=5, y=5)
assert result["geometry_index"] == 0
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_pad_sketch(self, register_tools, mock_bridge):
"""pad_sketch should extrude a sketch via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"name": "Pad", "label": "Pad", "type_id": "PartDesign::Pad"},
stdout="",
stderr="",
execution_time_ms=15.0,
)
)
pad_sketch = register_tools["pad_sketch"]
result = await pad_sketch(sketch_name="Sketch", length=10)
assert result["name"] == "Pad"
assert result["type_id"] == "PartDesign::Pad"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_pocket_sketch(self, register_tools, mock_bridge):
"""pocket_sketch should cut into solid via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"name": "Pocket",
"label": "Pocket",
"type_id": "PartDesign::Pocket",
},
stdout="",
stderr="",
execution_time_ms=15.0,
)
)
pocket_sketch = register_tools["pocket_sketch"]
result = await pocket_sketch(sketch_name="Sketch", length=5)
assert result["name"] == "Pocket"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_revolution_sketch(self, register_tools, mock_bridge):
"""revolution_sketch should revolve a sketch via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"name": "Revolution",
"label": "Revolution",
"type_id": "PartDesign::Revolution",
},
stdout="",
stderr="",
execution_time_ms=20.0,
)
)
revolution = register_tools["revolution_sketch"]
result = await revolution(sketch_name="Sketch", angle=360)
assert result["name"] == "Revolution"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_groove_sketch(self, register_tools, mock_bridge):
"""groove_sketch should cut by revolving via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"name": "Groove",
"label": "Groove",
"type_id": "PartDesign::Groove",
},
stdout="",
stderr="",
execution_time_ms=20.0,
)
)
groove = register_tools["groove_sketch"]
result = await groove(sketch_name="Sketch", angle=180)
assert result["name"] == "Groove"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_fillet_edges(self, register_tools, mock_bridge):
"""fillet_edges should add rounded edges via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"name": "Fillet",
"label": "Fillet",
"type_id": "PartDesign::Fillet",
},
stdout="",
stderr="",
execution_time_ms=10.0,
)
)
fillet = register_tools["fillet_edges"]
result = await fillet(object_name="Pad", radius=2.0)
assert result["name"] == "Fillet"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_chamfer_edges(self, register_tools, mock_bridge):
"""chamfer_edges should add beveled edges via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"name": "Chamfer",
"label": "Chamfer",
"type_id": "PartDesign::Chamfer",
},
stdout="",
stderr="",
execution_time_ms=10.0,
)
)
chamfer = register_tools["chamfer_edges"]
result = await chamfer(object_name="Pad", size=1.0)
assert result["name"] == "Chamfer"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_create_hole(self, register_tools, mock_bridge):
"""create_hole should create parametric holes via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"name": "Hole", "label": "Hole", "type_id": "PartDesign::Hole"},
stdout="",
stderr="",
execution_time_ms=15.0,
)
)
create_hole = register_tools["create_hole"]
result = await create_hole(sketch_name="HoleSketch", diameter=6.0, depth=10.0)
assert result["name"] == "Hole"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_linear_pattern(self, register_tools, mock_bridge):
"""linear_pattern should create linear pattern via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"name": "LinearPattern",
"label": "LinearPattern",
"type_id": "PartDesign::LinearPattern",
},
stdout="",
stderr="",
execution_time_ms=20.0,
)
)
pattern = register_tools["linear_pattern"]
result = await pattern(
feature_name="Pad", direction="X", length=50, occurrences=5
)
assert result["name"] == "LinearPattern"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_polar_pattern(self, register_tools, mock_bridge):
"""polar_pattern should create circular pattern via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"name": "PolarPattern",
"label": "PolarPattern",
"type_id": "PartDesign::PolarPattern",
},
stdout="",
stderr="",
execution_time_ms=20.0,
)
)
pattern = register_tools["polar_pattern"]
result = await pattern(feature_name="Pad", axis="Z", angle=360, occurrences=6)
assert result["name"] == "PolarPattern"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_mirrored_feature(self, register_tools, mock_bridge):
"""mirrored_feature should mirror a feature via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"name": "Mirrored",
"label": "Mirrored",
"type_id": "PartDesign::Mirrored",
},
stdout="",
stderr="",
execution_time_ms=15.0,
)
)
mirrored = register_tools["mirrored_feature"]
result = await mirrored(feature_name="Pad", plane="XY")
assert result["name"] == "Mirrored"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_loft_sketches(self, register_tools, mock_bridge):
"""loft_sketches should create a loft via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"name": "Loft",
"label": "Loft",
"type_id": "PartDesign::AdditiveLoft",
},
stdout="",
stderr="",
execution_time_ms=25.0,
)
)
loft = register_tools["loft_sketches"]
result = await loft(sketch_names=["Sketch", "Sketch001"])
assert result["name"] == "Loft"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_sweep_sketch(self, register_tools, mock_bridge):
"""sweep_sketch should sweep a profile via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"name": "Sweep",
"label": "Sweep",
"type_id": "PartDesign::AdditivePipe",
},
stdout="",
stderr="",
execution_time_ms=25.0,
)
)
sweep = register_tools["sweep_sketch"]
result = await sweep(profile_sketch="Profile", spine_sketch="Spine")
assert result["name"] == "Sweep"
mock_bridge.execute_python.assert_called_once()
+549
View File
@@ -0,0 +1,549 @@
"""Tests for view and GUI tools module."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from freecad_mcp.bridge.base import ExecutionResult, ScreenshotResult, WorkbenchInfo
class TestViewTools:
"""Tests for view and GUI tools."""
@pytest.fixture
def mock_mcp(self):
"""Create a mock MCP server that captures tool registrations."""
mcp = MagicMock()
mcp._registered_tools = {}
def tool_decorator():
def wrapper(func):
mcp._registered_tools[func.__name__] = func
return func
return wrapper
mcp.tool = tool_decorator
return mcp
@pytest.fixture
def mock_bridge(self):
"""Create a mock FreeCAD bridge."""
return AsyncMock()
@pytest.fixture
def register_tools(self, mock_mcp, mock_bridge):
"""Register view tools and return the registered functions."""
from freecad_mcp.tools.view import register_view_tools
async def get_bridge():
return mock_bridge
register_view_tools(mock_mcp, get_bridge)
return mock_mcp._registered_tools
@pytest.mark.asyncio
async def test_get_screenshot_success(self, register_tools, mock_bridge):
"""get_screenshot should return base64 image data."""
# get_screenshot calls bridge.get_screenshot which returns ScreenshotResult
mock_bridge.get_screenshot = AsyncMock(
return_value=ScreenshotResult(
success=True,
data="iVBORw0KGgo...", # Base64 PNG data
format="png",
width=800,
height=600,
error=None,
)
)
get_screenshot = register_tools["get_screenshot"]
result = await get_screenshot(view_angle="Isometric")
assert result["success"] is True
assert "data" in result
assert result["format"] == "png"
mock_bridge.get_screenshot.assert_called_once()
@pytest.mark.asyncio
async def test_get_screenshot_custom_size(self, register_tools, mock_bridge):
"""get_screenshot should accept width and height parameters."""
mock_bridge.get_screenshot = AsyncMock(
return_value=ScreenshotResult(
success=True,
data="...",
format="png",
width=1920,
height=1080,
error=None,
)
)
get_screenshot = register_tools["get_screenshot"]
result = await get_screenshot(width=1920, height=1080)
assert result["width"] == 1920
assert result["height"] == 1080
@pytest.mark.asyncio
async def test_get_screenshot_headless_error(self, register_tools, mock_bridge):
"""get_screenshot should return error in headless mode."""
mock_bridge.get_screenshot = AsyncMock(
return_value=ScreenshotResult(
success=False,
data=None,
format="png",
width=0,
height=0,
error="GUI not available - screenshot cannot be captured in headless mode",
)
)
get_screenshot = register_tools["get_screenshot"]
result = await get_screenshot()
assert result["success"] is False
assert "headless" in result["error"]
@pytest.mark.asyncio
async def test_get_screenshot_invalid_view_angle(self, register_tools, mock_bridge):
"""get_screenshot should return error for invalid view angle."""
get_screenshot = register_tools["get_screenshot"]
result = await get_screenshot(view_angle="InvalidAngle")
assert result["success"] is False
assert "Invalid view_angle" in result["error"]
@pytest.mark.asyncio
async def test_set_view_angle(self, register_tools, mock_bridge):
"""set_view_angle should set the camera view via bridge.set_view."""
mock_bridge.set_view = AsyncMock(return_value=None)
set_view_angle = register_tools["set_view_angle"]
result = await set_view_angle(view_angle="Front")
assert result["success"] is True
mock_bridge.set_view.assert_called_once()
@pytest.mark.asyncio
async def test_set_view_angle_invalid(self, register_tools, mock_bridge):
"""set_view_angle should return error for invalid view angle."""
set_view_angle = register_tools["set_view_angle"]
result = await set_view_angle(view_angle="InvalidAngle")
assert result["success"] is False
assert "Invalid view_angle" in result["error"]
@pytest.mark.asyncio
async def test_fit_all(self, register_tools, mock_bridge):
"""fit_all should zoom to fit all objects via bridge.set_view."""
mock_bridge.set_view = AsyncMock(return_value=None)
fit_all = register_tools["fit_all"]
result = await fit_all()
assert result["success"] is True
mock_bridge.set_view.assert_called_once()
@pytest.mark.asyncio
async def test_set_object_visibility(self, register_tools, mock_bridge):
"""set_object_visibility should show/hide objects via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"success": True, "visible": False},
stdout="",
stderr="",
execution_time_ms=10.0,
)
)
set_visibility = register_tools["set_object_visibility"]
result = await set_visibility(object_name="Box", visible=False)
assert result["success"] is True
assert result["visible"] is False
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_set_object_visibility_headless(self, register_tools, mock_bridge):
"""set_object_visibility should return error in headless mode."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"success": False,
"error": "GUI not available - visibility cannot be set in headless mode",
},
stdout="",
stderr="",
execution_time_ms=5.0,
)
)
set_visibility = register_tools["set_object_visibility"]
result = await set_visibility(object_name="Box", visible=True)
assert result["success"] is False
assert "headless" in result["error"]
@pytest.mark.asyncio
async def test_set_display_mode(self, register_tools, mock_bridge):
"""set_display_mode should change display mode via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"success": True, "mode": "Wireframe"},
stdout="",
stderr="",
execution_time_ms=10.0,
)
)
set_mode = register_tools["set_display_mode"]
result = await set_mode(object_name="Box", mode="Wireframe")
assert result["success"] is True
assert result["mode"] == "Wireframe"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_set_object_color(self, register_tools, mock_bridge):
"""set_object_color should change object color via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"success": True, "color": [1.0, 0.0, 0.0]},
stdout="",
stderr="",
execution_time_ms=10.0,
)
)
set_color = register_tools["set_object_color"]
result = await set_color(object_name="Box", color=[1.0, 0.0, 0.0])
assert result["success"] is True
assert result["color"] == [1.0, 0.0, 0.0] # Red
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_set_object_color_invalid_color(self, register_tools, mock_bridge):
"""set_object_color should validate color array length."""
set_color = register_tools["set_object_color"]
result = await set_color(object_name="Box", color=[1.0, 0.0]) # Missing blue
assert result["success"] is False
assert "must be [r, g, b]" in result["error"]
@pytest.mark.asyncio
async def test_list_workbenches(self, register_tools, mock_bridge):
"""list_workbenches should return available workbenches."""
mock_workbenches = [
WorkbenchInfo(
name="PartDesignWorkbench",
label="Part Design",
icon="",
is_active=True,
),
WorkbenchInfo(
name="SketcherWorkbench",
label="Sketcher",
icon="",
is_active=False,
),
]
mock_bridge.get_workbenches = AsyncMock(return_value=mock_workbenches)
list_workbenches = register_tools["list_workbenches"]
result = await list_workbenches()
assert len(result) == 2
assert result[0]["name"] == "PartDesignWorkbench"
assert result[0]["is_active"] is True
@pytest.mark.asyncio
async def test_activate_workbench(self, register_tools, mock_bridge):
"""activate_workbench should switch to a workbench."""
mock_bridge.activate_workbench = AsyncMock(return_value=None)
activate = register_tools["activate_workbench"]
result = await activate(workbench_name="SketcherWorkbench")
assert result["success"] is True
mock_bridge.activate_workbench.assert_called_once_with("SketcherWorkbench")
@pytest.mark.asyncio
async def test_zoom_in(self, register_tools, mock_bridge):
"""zoom_in should increase zoom level via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"success": True},
stdout="",
stderr="",
execution_time_ms=10.0,
)
)
zoom_in = register_tools["zoom_in"]
result = await zoom_in(factor=2.0)
assert result["success"] is True
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_zoom_out(self, register_tools, mock_bridge):
"""zoom_out should decrease zoom level via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"success": True},
stdout="",
stderr="",
execution_time_ms=10.0,
)
)
zoom_out = register_tools["zoom_out"]
result = await zoom_out(factor=2.0)
assert result["success"] is True
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_set_camera_position(self, register_tools, mock_bridge):
"""set_camera_position should set camera location via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"success": True},
stdout="",
stderr="",
execution_time_ms=15.0,
)
)
set_camera = register_tools["set_camera_position"]
result = await set_camera(
position=[100.0, 100.0, 100.0], look_at=[0.0, 0.0, 0.0]
)
assert result["success"] is True
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_undo(self, register_tools, mock_bridge):
"""undo should undo the last operation via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"success": True, "can_undo": True},
stdout="",
stderr="",
execution_time_ms=5.0,
)
)
undo = register_tools["undo"]
result = await undo()
assert result["success"] is True
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_redo(self, register_tools, mock_bridge):
"""redo should redo an undone operation via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"success": True, "can_redo": False},
stdout="",
stderr="",
execution_time_ms=5.0,
)
)
redo = register_tools["redo"]
result = await redo()
assert result["success"] is True
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_get_undo_redo_status(self, register_tools, mock_bridge):
"""get_undo_redo_status should return available operations via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"undo_count": 5,
"redo_count": 2,
"undo_names": ["Create Box", "Edit Box", "Create Fillet"],
},
stdout="",
stderr="",
execution_time_ms=5.0,
)
)
get_status = register_tools["get_undo_redo_status"]
result = await get_status()
assert result["undo_count"] == 5
assert result["redo_count"] == 2
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_list_parts_library(self, register_tools, mock_bridge):
"""list_parts_library should return available parts via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result=[
{
"name": "bolt_m6.FCStd",
"path": "/lib/bolt_m6.FCStd",
"category": "Fasteners",
},
{
"name": "nut_m6.FCStd",
"path": "/lib/nut_m6.FCStd",
"category": "Fasteners",
},
],
stdout="",
stderr="",
execution_time_ms=50.0,
)
)
list_parts = register_tools["list_parts_library"]
result = await list_parts()
assert len(result) == 2
assert result[0]["name"] == "bolt_m6.FCStd"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_list_parts_library_empty(self, register_tools, mock_bridge):
"""list_parts_library should return empty list when no parts found."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result=[],
stdout="",
stderr="",
execution_time_ms=30.0,
)
)
list_parts = register_tools["list_parts_library"]
result = await list_parts()
assert result == []
@pytest.mark.asyncio
async def test_insert_part_from_library(self, register_tools, mock_bridge):
"""insert_part_from_library should insert a part via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"name": "Bolt",
"label": "Bolt",
"type_id": "Part::Feature",
},
stdout="",
stderr="",
execution_time_ms=100.0,
)
)
insert_part = register_tools["insert_part_from_library"]
result = await insert_part(
part_path="/lib/bolt_m6.FCStd", position=[10.0, 20.0, 0.0]
)
assert result["name"] == "Bolt"
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_get_console_log(self, register_tools, mock_bridge):
"""get_console_log should return console messages."""
mock_bridge.get_console_output = AsyncMock(
return_value=[
"Info: Started",
"Info: Complete",
"Warning: Deprecated feature",
]
)
get_log = register_tools["get_console_log"]
result = await get_log(lines=50)
assert len(result["messages"]) == 3
assert len(result["warnings"]) == 1
assert len(result["errors"]) == 0
mock_bridge.get_console_output.assert_called_once_with(50)
@pytest.mark.asyncio
async def test_get_console_log_with_errors(self, register_tools, mock_bridge):
"""get_console_log should categorize error messages."""
mock_bridge.get_console_output = AsyncMock(
return_value=[
"Info: Started",
"Error: Failed to load module",
"Warning: Deprecated API",
]
)
get_log = register_tools["get_console_log"]
result = await get_log()
assert len(result["messages"]) == 3
assert len(result["errors"]) == 1
assert "Failed to load module" in result["errors"][0]
@pytest.mark.asyncio
async def test_recompute(self, register_tools, mock_bridge):
"""recompute should force document recomputation via execute_python."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={"success": True, "touch_count": 3},
stdout="",
stderr="",
execution_time_ms=20.0,
)
)
recompute = register_tools["recompute"]
result = await recompute()
assert result["success"] is True
assert result["touch_count"] == 3
mock_bridge.execute_python.assert_called_once()
@pytest.mark.asyncio
async def test_recompute_no_document(self, register_tools, mock_bridge):
"""recompute should handle no document gracefully."""
mock_bridge.execute_python = AsyncMock(
return_value=ExecutionResult(
success=True,
result={
"success": False,
"error": "No document found",
"touch_count": 0,
},
stdout="",
stderr="",
execution_time_ms=5.0,
)
)
recompute = register_tools["recompute"]
result = await recompute()
assert result["success"] is False
assert "No document" in result["error"]
Generated
+1209 -1183
View File
File diff suppressed because it is too large Load Diff