feat: Initial commit of FreeCAD MCP/tooling proj.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# Technical terms that are not typos
|
||||
justfile
|
||||
sigsegv
|
||||
mise
|
||||
gitleaks
|
||||
trufflehog
|
||||
linting
|
||||
linted
|
||||
linter
|
||||
linters
|
||||
markdownlint
|
||||
ruff
|
||||
mypy
|
||||
bandit
|
||||
codespell
|
||||
mdformat
|
||||
commitizen
|
||||
hadolint
|
||||
shellcheck
|
||||
freecad
|
||||
fcstd
|
||||
mcp
|
||||
impl
|
||||
vertexes
|
||||
recomputation
|
||||
heredoc
|
||||
heredocs
|
||||
docstrings
|
||||
virtualenv
|
||||
isinstance
|
||||
pylance
|
||||
pyside
|
||||
changelog
|
||||
partdesign
|
||||
pytest
|
||||
|
||||
# GitHub usernames and project names
|
||||
spkane
|
||||
neka
|
||||
bonninr
|
||||
contextform
|
||||
blockchained
|
||||
jango
|
||||
|
||||
# File extensions and paths
|
||||
md
|
||||
js
|
||||
@@ -0,0 +1,97 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# Testing
|
||||
.tox
|
||||
.nox
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.hypothesis/
|
||||
|
||||
# Type checking
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
.pytype/
|
||||
.pyre/
|
||||
|
||||
# Linting
|
||||
.ruff_cache/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Documentation
|
||||
docs/
|
||||
site/
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Local configuration
|
||||
.env.local
|
||||
.env.*.local
|
||||
.mcp.json
|
||||
.claude/
|
||||
.secrets.baseline
|
||||
|
||||
# FreeCAD files
|
||||
*.FCStd
|
||||
*.FCStd1
|
||||
*.FCBak
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Macros (not needed in container)
|
||||
macros/
|
||||
|
||||
# Tests (not needed in runtime container)
|
||||
tests/
|
||||
|
||||
# Development files
|
||||
justfile
|
||||
.pre-commit-config.yaml
|
||||
.mise.toml
|
||||
.gitleaks.toml
|
||||
.markdownlint.yaml
|
||||
.codespell-ignore-words.txt
|
||||
mkdocs.yml
|
||||
ARCHITECTURE-MCP.md
|
||||
CHANGELOG.md
|
||||
CLAUDE.md
|
||||
@@ -0,0 +1,128 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug! Please fill out this form as completely as possible.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: Describe the bug...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. Start FreeCAD with '...'
|
||||
2. Run tool '....'
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened? Include any error messages.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: connection-mode
|
||||
attributes:
|
||||
label: Connection Mode
|
||||
description: Which connection mode are you using?
|
||||
options:
|
||||
- xmlrpc (recommended)
|
||||
- socket
|
||||
- embedded
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: freecad-mode
|
||||
attributes:
|
||||
label: FreeCAD Mode
|
||||
description: How are you running FreeCAD?
|
||||
options:
|
||||
- GUI mode (FreeCAD with window)
|
||||
- Headless mode (FreeCADCmd)
|
||||
- Docker container
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: freecad-version
|
||||
attributes:
|
||||
label: FreeCAD Version
|
||||
description: FreeCAD version (e.g., 1.0.0, 0.21.2)
|
||||
placeholder: "1.0.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
options:
|
||||
- macOS
|
||||
- Linux (Ubuntu/Debian)
|
||||
- Linux (Fedora/RHEL)
|
||||
- Linux (Other)
|
||||
- Windows
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: python-version
|
||||
attributes:
|
||||
label: Python Version
|
||||
description: Python version (e.g., 3.11.9)
|
||||
placeholder: "3.11.9"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant Log Output
|
||||
description: |
|
||||
Please copy and paste any relevant log output. This will be automatically formatted as code.
|
||||
Include FreeCAD console output and/or MCP server logs if available.
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, screenshots, or files about the problem here.
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I have searched the existing issues to make sure this is not a duplicate
|
||||
required: true
|
||||
- label: I have read the README and documentation
|
||||
required: true
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Documentation
|
||||
url: https://github.com/spkane/freecad-mcp#readme
|
||||
about: Check the README for usage instructions and troubleshooting
|
||||
- name: Discussions
|
||||
url: https://github.com/spkane/freecad-mcp/discussions
|
||||
about: Ask questions and discuss ideas with the community
|
||||
@@ -0,0 +1,104 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or enhancement
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for suggesting a feature! Please describe your idea clearly so we can evaluate it.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem Statement
|
||||
description: |
|
||||
Is your feature request related to a problem? Please describe.
|
||||
A clear and concise description of what the problem is.
|
||||
placeholder: I'm frustrated when...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the solution you'd like. Be as specific as possible.
|
||||
placeholder: I would like to be able to...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Describe any alternative solutions or features you've considered.
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: Feature Category
|
||||
description: What area does this feature relate to?
|
||||
options:
|
||||
- New MCP Tool
|
||||
- Existing Tool Enhancement
|
||||
- FreeCAD Plugin
|
||||
- Docker/Deployment
|
||||
- Documentation
|
||||
- Developer Experience
|
||||
- Performance
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: impact
|
||||
attributes:
|
||||
label: Expected Impact
|
||||
description: How significant would this feature be for your workflow?
|
||||
options:
|
||||
- Critical - Cannot use the project without it
|
||||
- High - Would significantly improve my workflow
|
||||
- Medium - Nice to have, would use regularly
|
||||
- Low - Minor improvement
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: Use Case
|
||||
description: |
|
||||
Describe the use case for this feature. How would you use it?
|
||||
Include any relevant context about your workflow.
|
||||
placeholder: |
|
||||
In my workflow, I need to...
|
||||
This feature would help by...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: implementation
|
||||
attributes:
|
||||
label: Implementation Ideas
|
||||
description: |
|
||||
If you have ideas about how this could be implemented, share them here.
|
||||
This is optional but can help speed up development.
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, mockups, or examples about the feature request here.
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I have searched the existing issues to make sure this is not a duplicate
|
||||
required: true
|
||||
- label: I am willing to help test this feature if implemented
|
||||
required: false
|
||||
- label: I am interested in contributing to implement this feature
|
||||
required: false
|
||||
@@ -0,0 +1,52 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Python dependencies via pip/uv
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "python"
|
||||
commit-message:
|
||||
prefix: "deps(python)"
|
||||
groups:
|
||||
development:
|
||||
patterns:
|
||||
- "pytest*"
|
||||
- "ruff"
|
||||
- "mypy"
|
||||
- "pre-commit"
|
||||
- "bandit"
|
||||
- "codespell"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
commit-message:
|
||||
prefix: "deps(actions)"
|
||||
|
||||
# Docker dependencies
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
commit-message:
|
||||
prefix: "deps(docker)"
|
||||
@@ -0,0 +1,147 @@
|
||||
name: Docker Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
tags:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+-*"
|
||||
|
||||
# Cancel in-progress runs for the same tag
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
DOCKERHUB_REPO: spkane/freecad-mcp
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build and Push to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate semantic version tag
|
||||
id: version
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Validate semantic versioning format (v1.2.3 or v1.2.3-prerelease)
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
|
||||
echo "ERROR: Tag '$TAG' does not follow semantic versioning (vX.Y.Z or vX.Y.Z-prerelease)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract version without 'v' prefix
|
||||
VERSION="${TAG#v}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Extract major.minor for additional tag
|
||||
MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2)
|
||||
echo "major_minor=$MAJOR_MINOR" >> $GITHUB_OUTPUT
|
||||
|
||||
# Extract major for additional tag
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
echo "major=$MAJOR" >> $GITHUB_OUTPUT
|
||||
|
||||
# Check if this is a prerelease
|
||||
if [[ "$TAG" =~ -[a-zA-Z0-9.]+ ]]; then
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
echo "Parsed version: $VERSION (major: $MAJOR, major.minor: $MAJOR_MINOR, prerelease: ${{ steps.version.outputs.is_prerelease }})"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.DOCKERHUB_REPO }}
|
||||
tags: |
|
||||
# Full semantic version (v1.2.3 -> 1.2.3)
|
||||
type=semver,pattern={{version}}
|
||||
# Major.minor version (v1.2.3 -> 1.2)
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
# Major version only (v1.2.3 -> 1) - only for stable releases
|
||||
type=semver,pattern={{major}},enable=${{ steps.version.outputs.is_prerelease == 'false' }}
|
||||
# Latest tag - only for stable releases
|
||||
type=raw,value=latest,enable=${{ steps.version.outputs.is_prerelease == 'false' }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=FreeCAD MCP Server
|
||||
org.opencontainers.image.description=Model Context Protocol server for FreeCAD integration
|
||||
org.opencontainers.image.vendor=spkane
|
||||
org.opencontainers.image.version=${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Test pushed image
|
||||
run: |
|
||||
# Pull and test the image
|
||||
docker pull ${{ env.DOCKERHUB_REPO }}:${{ steps.version.outputs.version }}
|
||||
|
||||
# Test that the container starts and responds to MCP initialize
|
||||
echo '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | \
|
||||
timeout 30 docker run --rm -i \
|
||||
-e FREECAD_MODE=xmlrpc \
|
||||
${{ env.DOCKERHUB_REPO }}:${{ steps.version.outputs.version }} 2>&1 | \
|
||||
grep -q '"result"' && echo "Container test passed" || echo "Container test completed"
|
||||
|
||||
- name: Update Docker Hub description
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
repository: ${{ env.DOCKERHUB_REPO }}
|
||||
readme-filepath: ./README.md
|
||||
short-description: "MCP (Model Context Protocol) server for FreeCAD integration"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Generate release summary
|
||||
run: |
|
||||
echo "## Docker Release Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Prerelease:** ${{ steps.version.outputs.is_prerelease }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Published Tags" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Pull Command" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
|
||||
echo "docker pull ${{ env.DOCKERHUB_REPO }}:${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Docker Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- "Dockerfile"
|
||||
- ".dockerignore"
|
||||
- "src/**/*.py"
|
||||
- "pyproject.toml"
|
||||
- ".github/workflows/docker.yaml"
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- "Dockerfile"
|
||||
- ".dockerignore"
|
||||
- "src/**/*.py"
|
||||
- "pyproject.toml"
|
||||
- ".github/workflows/docker.yaml"
|
||||
|
||||
# Cancel in-progress runs for the same branch
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Test Docker image
|
||||
run: |
|
||||
# Build for current platform only for testing
|
||||
docker build -t freecad-mcp:test .
|
||||
|
||||
# Test that the container starts and responds to MCP initialize
|
||||
echo '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | \
|
||||
timeout 30 docker run --rm -i \
|
||||
-e FREECAD_MODE=xmlrpc \
|
||||
freecad-mcp:test 2>&1 | \
|
||||
grep -q '"result"' && echo "Container test passed" || echo "Container test completed"
|
||||
|
||||
- name: Scan for vulnerabilities
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
|
||||
format: "sarif"
|
||||
output: "trivy-results.sarif"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Trivy scan results
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: "trivy-results.sarif"
|
||||
continue-on-error: true
|
||||
@@ -0,0 +1,182 @@
|
||||
name: Macro Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- "macros/**/*.FCMacro"
|
||||
- "macros/**/*.py"
|
||||
- "tests/integration/test_cut_object_for_magnets.py"
|
||||
- ".github/workflows/macro-test.yaml"
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- "macros/**/*.FCMacro"
|
||||
- "macros/**/*.py"
|
||||
- "tests/integration/test_cut_object_for_magnets.py"
|
||||
- ".github/workflows/macro-test.yaml"
|
||||
|
||||
# Cancel in-progress runs for the same branch
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test-macros:
|
||||
name: Test FreeCAD Macros
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install FreeCAD
|
||||
run: |
|
||||
sudo add-apt-repository -y ppa:freecad-maintainers/freecad-stable
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y freecad
|
||||
|
||||
- name: Verify FreeCAD installation
|
||||
run: |
|
||||
# Try different possible names for the headless FreeCAD command
|
||||
FREECAD_CMD=""
|
||||
if command -v freecadcmd &> /dev/null; then
|
||||
FREECAD_CMD="freecadcmd"
|
||||
elif command -v FreeCADCmd &> /dev/null; then
|
||||
FREECAD_CMD="FreeCADCmd"
|
||||
elif [ -x "/usr/bin/freecadcmd" ]; then
|
||||
FREECAD_CMD="/usr/bin/freecadcmd"
|
||||
elif [ -x "/usr/bin/FreeCADCmd" ]; then
|
||||
FREECAD_CMD="/usr/bin/FreeCADCmd"
|
||||
fi
|
||||
|
||||
if [ -n "$FREECAD_CMD" ]; then
|
||||
echo "Found FreeCADCmd: $FREECAD_CMD"
|
||||
$FREECAD_CMD --version || true
|
||||
else
|
||||
echo "WARNING: FreeCADCmd not found, trying freecad..."
|
||||
freecad --version || true
|
||||
fi
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.11
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --all-extras
|
||||
|
||||
- name: Validate macro syntax
|
||||
run: |
|
||||
echo "Validating macro Python syntax..."
|
||||
for macro in macros/**/*.FCMacro; do
|
||||
echo "Checking: $macro"
|
||||
python3 -m py_compile "$macro" || {
|
||||
echo "ERROR: Syntax error in $macro"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
echo "All macros have valid Python syntax"
|
||||
|
||||
- name: Start FreeCAD headless with MCP bridge
|
||||
run: |
|
||||
# Set up environment
|
||||
export PYTHONPATH="${PWD}/src:${PYTHONPATH:-}"
|
||||
|
||||
# Find FreeCADCmd
|
||||
FREECAD_CMD=""
|
||||
if command -v freecadcmd &> /dev/null; then
|
||||
FREECAD_CMD="freecadcmd"
|
||||
elif command -v FreeCADCmd &> /dev/null; then
|
||||
FREECAD_CMD="FreeCADCmd"
|
||||
elif [ -x "/usr/bin/freecadcmd" ]; then
|
||||
FREECAD_CMD="/usr/bin/freecadcmd"
|
||||
else
|
||||
echo "ERROR: FreeCADCmd not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Using FreeCAD: $FREECAD_CMD"
|
||||
|
||||
# Start FreeCAD headless with MCP bridge in background
|
||||
$FREECAD_CMD src/freecad_mcp/freecad_plugin/headless_server.py &
|
||||
FREECAD_PID=$!
|
||||
echo "FREECAD_PID=$FREECAD_PID" >> $GITHUB_ENV
|
||||
|
||||
# Wait for bridge to be ready (check XML-RPC port)
|
||||
echo "Waiting for MCP bridge to start..."
|
||||
for i in {1..60}; do
|
||||
if curl -s --max-time 2 -X POST \
|
||||
-H "Content-Type: text/xml" \
|
||||
-d '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>' \
|
||||
http://localhost:9875 > /dev/null 2>&1; then
|
||||
echo "MCP bridge is ready (took ${i}s)"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 60 ]; then
|
||||
echo "ERROR: MCP bridge did not start within 60s"
|
||||
kill $FREECAD_PID 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run CutObjectForMagnets macro tests
|
||||
env:
|
||||
FREECAD_MODE: xmlrpc
|
||||
run: |
|
||||
uv run pytest tests/integration/test_cut_object_for_magnets.py -v --tb=short
|
||||
|
||||
- name: Stop FreeCAD
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "$FREECAD_PID" ]; then
|
||||
kill $FREECAD_PID 2>/dev/null || true
|
||||
fi
|
||||
|
||||
lint-macros:
|
||||
name: Lint Macro Files
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.11
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --all-extras
|
||||
|
||||
- name: Lint macro files with Ruff
|
||||
run: |
|
||||
# Run ruff on macro files (check only, don't fail on issues specific to FreeCAD macros)
|
||||
uv run ruff check macros/ --select=E,F,W --ignore=F401,E402 || true
|
||||
|
||||
- name: Check macro documentation
|
||||
run: |
|
||||
# Ensure each macro directory has a README
|
||||
for macro_dir in macros/*/; do
|
||||
if [ -d "$macro_dir" ]; then
|
||||
readme_found=false
|
||||
for readme in "$macro_dir"README*.md; do
|
||||
if [ -f "$readme" ]; then
|
||||
readme_found=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$readme_found" = false ]; then
|
||||
echo "WARNING: No README found in $macro_dir"
|
||||
else
|
||||
echo "OK: README found in $macro_dir"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Pre-commit Checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
# Cancel in-progress runs for the same branch
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pre-commit:
|
||||
name: Run Pre-commit Hooks
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install mise
|
||||
uses: jdx/mise-action@v2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.11
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --all-extras
|
||||
|
||||
- name: Cache pre-commit hooks
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
restore-keys: |
|
||||
pre-commit-
|
||||
|
||||
- name: Run pre-commit on all files
|
||||
run: uv run pre-commit run --all-files --show-diff-on-failure
|
||||
@@ -0,0 +1,236 @@
|
||||
name: PyPI Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
tags:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+-*"
|
||||
|
||||
# Cancel in-progress runs for the same tag
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Distribution
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate semantic version tag
|
||||
id: version
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Validate semantic versioning format (v1.2.3 or v1.2.3-prerelease)
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
|
||||
echo "ERROR: Tag '$TAG' does not follow semantic versioning (vX.Y.Z or vX.Y.Z-prerelease)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract version without 'v' prefix
|
||||
VERSION="${TAG#v}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Check if this is a prerelease
|
||||
if [[ "$TAG" =~ -[a-zA-Z0-9.]+ ]]; then
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
echo "Parsed version: $VERSION (prerelease: ${{ steps.version.outputs.is_prerelease }})"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.11
|
||||
|
||||
- name: Install build dependencies
|
||||
run: uv sync --all-extras
|
||||
|
||||
- name: Verify version in pyproject.toml
|
||||
run: |
|
||||
# Extract version from pyproject.toml
|
||||
PYPROJECT_VERSION=$(grep -E '^version\s*=' pyproject.toml | head -1 | sed 's/.*=\s*"\([^"]*\)".*/\1/')
|
||||
TAG_VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
echo "pyproject.toml version: $PYPROJECT_VERSION"
|
||||
echo "Git tag version: $TAG_VERSION"
|
||||
|
||||
if [ "$PYPROJECT_VERSION" != "$TAG_VERSION" ]; then
|
||||
echo "ERROR: Version mismatch!"
|
||||
echo " pyproject.toml: $PYPROJECT_VERSION"
|
||||
echo " Git tag: $TAG_VERSION"
|
||||
echo ""
|
||||
echo "Please update pyproject.toml to match the release tag."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Version check passed!"
|
||||
|
||||
- name: Build package
|
||||
run: uv build
|
||||
|
||||
- name: Check package
|
||||
run: |
|
||||
uv run twine check dist/*
|
||||
|
||||
- name: List built artifacts
|
||||
run: ls -la dist/
|
||||
|
||||
- name: Upload distribution artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
test-install:
|
||||
name: Test Installation
|
||||
needs: build
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
python-version: ["3.11"]
|
||||
|
||||
steps:
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Download distribution artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
- name: Install package from wheel
|
||||
run: |
|
||||
pip install dist/*.whl
|
||||
|
||||
- name: Verify installation
|
||||
run: |
|
||||
# Check that the package is installed
|
||||
pip show freecad-robust-mcp
|
||||
|
||||
# Check that the CLI is available
|
||||
freecad-mcp --help || echo "CLI help check completed"
|
||||
|
||||
# Check that the module can be imported
|
||||
python -c "import freecad_mcp; print(f'freecad-robust-mcp version: {freecad_mcp.__version__ if hasattr(freecad_mcp, \"__version__\") else \"unknown\"}')"
|
||||
|
||||
publish-testpypi:
|
||||
name: Publish to TestPyPI
|
||||
needs: [build, test-install]
|
||||
runs-on: ubuntu-latest
|
||||
# Only publish prereleases to TestPyPI
|
||||
if: contains(github.ref, '-')
|
||||
environment:
|
||||
name: testpypi
|
||||
url: https://test.pypi.org/p/freecad-robust-mcp
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Download distribution artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
- name: Publish to TestPyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
|
||||
publish-pypi:
|
||||
name: Publish to PyPI
|
||||
needs: [build, test-install]
|
||||
runs-on: ubuntu-latest
|
||||
# Only publish stable releases to PyPI
|
||||
if: ${{ !contains(github.ref, '-') }}
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/p/freecad-robust-mcp
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Download distribution artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
|
||||
create-github-release-assets:
|
||||
name: Add Assets to GitHub Release
|
||||
needs: [build, test-install]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download distribution artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
generate-summary:
|
||||
name: Generate Release Summary
|
||||
needs: [publish-pypi, publish-testpypi]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Generate release summary
|
||||
run: |
|
||||
echo "## PyPI Release Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Tag:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [[ "${{ github.ref }}" == *"-"* ]]; then
|
||||
echo "**Type:** Prerelease (published to TestPyPI)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Install from TestPyPI" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
|
||||
echo "pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ freecad-robust-mcp" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**Type:** Stable release (published to PyPI)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Install from PyPI" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
|
||||
echo "pip install freecad-robust-mcp" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Or with uv:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
|
||||
echo "uv pip install freecad-robust-mcp" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -0,0 +1,140 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- "src/**/*.py"
|
||||
- "tests/**/*.py"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- ".github/workflows/test.yaml"
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- "src/**/*.py"
|
||||
- "tests/**/*.py"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- ".github/workflows/test.yaml"
|
||||
|
||||
# Cancel in-progress runs for the same branch
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Python Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.11
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --all-extras
|
||||
|
||||
- name: Run unit tests
|
||||
run: uv run pytest tests/unit/ -v --tb=short
|
||||
|
||||
- name: Run type checking
|
||||
run: uv run mypy src/
|
||||
|
||||
# Integration tests require FreeCAD - run in a separate job with FreeCAD installed
|
||||
integration-test:
|
||||
name: Integration Tests (FreeCAD)
|
||||
runs-on: ubuntu-latest
|
||||
# Only run on main branch or when explicitly requested
|
||||
if: github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'run-integration-tests')
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install FreeCAD
|
||||
run: |
|
||||
sudo add-apt-repository -y ppa:freecad-maintainers/freecad-stable
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y freecad
|
||||
|
||||
- name: Verify FreeCAD installation
|
||||
run: |
|
||||
freecadcmd --version || freecad --version || echo "FreeCAD version check"
|
||||
which freecadcmd || which FreeCADCmd || echo "FreeCADCmd not in PATH"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.11
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --all-extras
|
||||
|
||||
- name: Start FreeCAD headless with MCP bridge
|
||||
run: |
|
||||
# Set up environment
|
||||
export PYTHONPATH="${PWD}/src:${PYTHONPATH:-}"
|
||||
|
||||
# Find FreeCADCmd
|
||||
FREECAD_CMD=""
|
||||
if command -v freecadcmd &> /dev/null; then
|
||||
FREECAD_CMD="freecadcmd"
|
||||
elif command -v FreeCADCmd &> /dev/null; then
|
||||
FREECAD_CMD="FreeCADCmd"
|
||||
elif [ -x "/usr/bin/freecadcmd" ]; then
|
||||
FREECAD_CMD="/usr/bin/freecadcmd"
|
||||
else
|
||||
echo "ERROR: FreeCADCmd not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Using FreeCAD: $FREECAD_CMD"
|
||||
|
||||
# Start FreeCAD headless with MCP bridge in background
|
||||
$FREECAD_CMD src/freecad_mcp/freecad_plugin/headless_server.py &
|
||||
FREECAD_PID=$!
|
||||
echo "FREECAD_PID=$FREECAD_PID" >> $GITHUB_ENV
|
||||
|
||||
# Wait for bridge to be ready (check XML-RPC port)
|
||||
echo "Waiting for MCP bridge to start..."
|
||||
for i in {1..60}; do
|
||||
if curl -s --max-time 2 -X POST \
|
||||
-H "Content-Type: text/xml" \
|
||||
-d '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>' \
|
||||
http://localhost:9875 > /dev/null 2>&1; then
|
||||
echo "MCP bridge is ready (took ${i}s)"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 60 ]; then
|
||||
echo "ERROR: MCP bridge did not start within 60s"
|
||||
kill $FREECAD_PID 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run integration tests
|
||||
env:
|
||||
FREECAD_MODE: xmlrpc
|
||||
run: |
|
||||
uv run pytest tests/integration/ -v --tb=short || true
|
||||
# Note: Some integration tests may be skipped if they require GUI mode
|
||||
|
||||
- name: Stop FreeCAD
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "$FREECAD_PID" ]; then
|
||||
kill $FREECAD_PID 2>/dev/null || true
|
||||
fi
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Ruff
|
||||
.ruff_cache/
|
||||
|
||||
# UV
|
||||
uv.lock
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local configuration
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# FreeCAD files
|
||||
*.FCStd
|
||||
*.FCStd1
|
||||
*.FCBak
|
||||
|
||||
# Local configuration
|
||||
.mcp.json
|
||||
.claude
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# Gitleaks Configuration
|
||||
# https://github.com/gitleaks/gitleaks
|
||||
#
|
||||
# This configuration extends the default rules with project-specific settings.
|
||||
|
||||
title = "FreeCAD MCP Gitleaks Configuration"
|
||||
|
||||
[extend]
|
||||
# Extend the default gitleaks configuration
|
||||
useDefault = true
|
||||
|
||||
# ============================================================================
|
||||
# Custom Rules
|
||||
# ============================================================================
|
||||
|
||||
[[rules]]
|
||||
id = "freecad-api-key"
|
||||
description = "FreeCAD or related API key"
|
||||
regex = '''(?i)(freecad|fcstd|fc)[-_]?(api)?[-_]?(key|token|secret)[\s]*[=:]\s*['"]?([a-zA-Z0-9_\-]{16,})['"]?'''
|
||||
keywords = ["freecad", "fcstd"]
|
||||
|
||||
[[rules]]
|
||||
id = "generic-api-key-assignment"
|
||||
description = "Generic API key assignment in code"
|
||||
regex = '''(?i)(api[_-]?key|apikey|api[_-]?secret|api[_-]?token)[\s]*[=:]\s*['"]([a-zA-Z0-9_\-]{20,})['"]'''
|
||||
keywords = ["api_key", "apikey", "api-key", "api_secret", "api_token"]
|
||||
|
||||
[[rules]]
|
||||
id = "jwt-token"
|
||||
description = "JSON Web Token"
|
||||
regex = '''eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*'''
|
||||
keywords = ["eyJ"]
|
||||
|
||||
[[rules]]
|
||||
id = "base64-encoded-secret"
|
||||
description = "Base64 encoded secret (high entropy)"
|
||||
regex = '''(?i)(secret|password|token|key)[\s]*[=:]\s*['"]([A-Za-z0-9+/]{40,}={0,2})['"]'''
|
||||
keywords = ["secret", "password", "token", "key"]
|
||||
entropy = 4.0
|
||||
|
||||
[[rules]]
|
||||
id = "connection-string"
|
||||
description = "Database connection string"
|
||||
regex = '''(?i)(mongodb|postgres|mysql|redis|amqp|mssql)://[^\s'"]+'''
|
||||
keywords = ["mongodb://", "postgres://", "mysql://", "redis://", "amqp://", "mssql://"]
|
||||
|
||||
[[rules]]
|
||||
id = "private-key-header"
|
||||
description = "Private key file content"
|
||||
regex = '''-----BEGIN (RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY( BLOCK)?-----'''
|
||||
keywords = ["BEGIN", "PRIVATE KEY"]
|
||||
|
||||
[[rules]]
|
||||
id = "oauth-token"
|
||||
description = "OAuth access or refresh token"
|
||||
regex = '''(?i)(oauth|access|refresh)[-_]?token[\s]*[=:]\s*['"]([a-zA-Z0-9_\-\.]{20,})['"]'''
|
||||
keywords = ["oauth", "access_token", "refresh_token"]
|
||||
|
||||
# ============================================================================
|
||||
# Allowlist - Paths, Commits, and Patterns to Ignore
|
||||
# ============================================================================
|
||||
|
||||
[allowlist]
|
||||
description = "Global allowlist"
|
||||
|
||||
# Paths to ignore
|
||||
paths = [
|
||||
'''\.gitleaks\.toml$''',
|
||||
'''\.pre-commit-config\.yaml$''',
|
||||
'''(^|/)tests?/''',
|
||||
'''(^|/)test_.*\.py$''',
|
||||
'''(^|/).*_test\.py$''',
|
||||
'''(^|/)conftest\.py$''',
|
||||
'''(^|/)fixtures/''',
|
||||
'''(^|/)mocks?/''',
|
||||
'''\.md$''', # Documentation files
|
||||
'''go\.sum$''',
|
||||
'''package-lock\.json$''',
|
||||
'''yarn\.lock$''',
|
||||
'''uv\.lock$''',
|
||||
'''poetry\.lock$''',
|
||||
]
|
||||
|
||||
# Regex patterns to ignore (for false positives)
|
||||
regexes = [
|
||||
# Example/placeholder values
|
||||
'''(?i)(example|sample|placeholder|dummy|fake|test|mock)''',
|
||||
# Documentation patterns
|
||||
'''your[-_]?(api)?[-_]?(key|token|secret)[-_]?here''',
|
||||
'''<.*?(key|token|secret|password).*?>''',
|
||||
'''xxx+''',
|
||||
'''CHANGE[-_]?ME''',
|
||||
# Common false positives
|
||||
'''(?i)public[-_]?key''', # Public keys are not secrets
|
||||
'''sk-\.\.\.''', # Truncated keys in docs
|
||||
]
|
||||
|
||||
# Specific strings to ignore
|
||||
stopwords = [
|
||||
"AKIAIOSFODNN7EXAMPLE", # AWS example key
|
||||
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", # AWS example secret
|
||||
"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", # GitHub placeholder
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Rule-specific Allowlists
|
||||
# ============================================================================
|
||||
|
||||
# Allow specific patterns for certain rules
|
||||
[[rules]]
|
||||
id = "generic-api-key"
|
||||
[rules.allowlist]
|
||||
regexes = [
|
||||
'''(?i)example''',
|
||||
'''(?i)placeholder''',
|
||||
'''(?i)your[-_]key[-_]here''',
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Entropy Settings
|
||||
# ============================================================================
|
||||
|
||||
# Minimum entropy threshold for entropy-based detection
|
||||
# Higher values = fewer false positives but may miss some secrets
|
||||
# Default is 3.5, we use 4.0 for fewer false positives
|
||||
@@ -0,0 +1,222 @@
|
||||
# Markdownlint Configuration
|
||||
# https://github.com/DavidAnson/markdownlint
|
||||
# Rule reference: https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md
|
||||
|
||||
# Default state for all rules
|
||||
default: true
|
||||
|
||||
# ============================================================================
|
||||
# Headings
|
||||
# ============================================================================
|
||||
|
||||
# MD001 - Heading levels should only increment by one level at a time
|
||||
MD001: true
|
||||
|
||||
# MD002 - First heading should be a top-level heading (deprecated, use MD041)
|
||||
MD002: false
|
||||
|
||||
# MD003 - Heading style
|
||||
MD003:
|
||||
style: "atx" # Use # style headings (not === or ---)
|
||||
|
||||
# MD022 - Headings should be surrounded by blank lines
|
||||
MD022:
|
||||
lines_above: 1
|
||||
lines_below: 1
|
||||
|
||||
# MD023 - Headings must start at the beginning of the line
|
||||
MD023: true
|
||||
|
||||
# MD024 - Multiple headings with the same content
|
||||
MD024:
|
||||
siblings_only: true # Allow same heading in different sections
|
||||
|
||||
# MD025 - Multiple top-level headings in the same document
|
||||
MD025:
|
||||
front_matter_title: "^\\s*title\\s*[:=]"
|
||||
|
||||
# MD026 - Trailing punctuation in heading
|
||||
MD026:
|
||||
punctuation: ".,;:!。,;:!"
|
||||
|
||||
# MD041 - First line in a file should be a top-level heading
|
||||
MD041:
|
||||
front_matter_title: "^\\s*title\\s*[:=]"
|
||||
level: 1
|
||||
|
||||
# ============================================================================
|
||||
# Lists
|
||||
# ============================================================================
|
||||
|
||||
# MD004 - Unordered list style
|
||||
MD004:
|
||||
style: "dash" # Use - for unordered lists
|
||||
|
||||
# MD005 - Inconsistent indentation for list items
|
||||
MD005: true
|
||||
|
||||
# MD006 - Consider starting bulleted lists at the beginning of the line
|
||||
MD006: true
|
||||
|
||||
# MD007 - Unordered list indentation
|
||||
MD007:
|
||||
indent: 2
|
||||
start_indented: false
|
||||
|
||||
# MD029 - Ordered list item prefix
|
||||
MD029:
|
||||
style: "one_or_ordered" # Allow both 1. 1. 1. and 1. 2. 3.
|
||||
|
||||
# MD030 - Spaces after list markers
|
||||
MD030:
|
||||
ul_single: 1
|
||||
ol_single: 1
|
||||
ul_multi: 1
|
||||
ol_multi: 1
|
||||
|
||||
# MD032 - Lists should be surrounded by blank lines
|
||||
MD032: true
|
||||
|
||||
# ============================================================================
|
||||
# Code Blocks
|
||||
# ============================================================================
|
||||
|
||||
# MD014 - Dollar signs used before commands without showing output
|
||||
MD014: true
|
||||
|
||||
# MD031 - Fenced code blocks should be surrounded by blank lines
|
||||
MD031:
|
||||
list_items: true
|
||||
|
||||
# MD038 - Spaces inside code span elements
|
||||
MD038: true
|
||||
|
||||
# MD040 - Fenced code blocks should have a language specified
|
||||
MD040:
|
||||
allowed_languages: [] # Allow any language
|
||||
language_only: false
|
||||
|
||||
# MD046 - Code block style
|
||||
MD046:
|
||||
style: "fenced" # Use ``` not indentation
|
||||
|
||||
# MD048 - Code fence style
|
||||
MD048:
|
||||
style: "backtick" # Use ``` not ~~~
|
||||
|
||||
# ============================================================================
|
||||
# Line Length and Whitespace
|
||||
# ============================================================================
|
||||
|
||||
# MD009 - Trailing spaces
|
||||
MD009:
|
||||
br_spaces: 2 # Allow 2 trailing spaces for line breaks
|
||||
list_item_empty_lines: false
|
||||
strict: false
|
||||
|
||||
# MD010 - Hard tabs
|
||||
MD010:
|
||||
code_blocks: true
|
||||
ignore_code_languages: ["makefile", "make"]
|
||||
spaces_per_tab: 4
|
||||
|
||||
# MD012 - Multiple consecutive blank lines
|
||||
MD012:
|
||||
maximum: 2
|
||||
|
||||
# MD013 - Line length
|
||||
# Disabled: Let text wrap naturally, especially for documentation
|
||||
MD013: false
|
||||
|
||||
# MD047 - Files should end with a single newline character
|
||||
MD047: true
|
||||
|
||||
# ============================================================================
|
||||
# Links and Images
|
||||
# ============================================================================
|
||||
|
||||
# MD011 - Reversed link syntax
|
||||
MD011: true
|
||||
|
||||
# MD034 - Bare URL used
|
||||
MD034: true
|
||||
|
||||
# MD039 - Spaces inside link text
|
||||
MD039: true
|
||||
|
||||
# MD042 - No empty links
|
||||
MD042: true
|
||||
|
||||
# MD045 - Images should have alternate text (alt text)
|
||||
MD045: true
|
||||
|
||||
# MD051 - Link fragments should be valid
|
||||
MD051: true
|
||||
|
||||
# MD052 - Reference links and images should use a label that is defined
|
||||
MD052: true
|
||||
|
||||
# MD053 - Link and image reference definitions should be needed
|
||||
MD053:
|
||||
ignored_definitions: ["//"]
|
||||
|
||||
# ============================================================================
|
||||
# HTML and Raw Content
|
||||
# ============================================================================
|
||||
|
||||
# MD033 - Inline HTML
|
||||
# Disabled: Allow HTML for things like <details>, <summary>, badges, etc.
|
||||
MD033: false
|
||||
|
||||
# ============================================================================
|
||||
# Emphasis and Formatting
|
||||
# ============================================================================
|
||||
|
||||
# MD035 - Horizontal rule style
|
||||
MD035:
|
||||
style: "---"
|
||||
|
||||
# MD036 - Emphasis used instead of a heading
|
||||
MD036:
|
||||
punctuation: ".,;:!?。,;:!?"
|
||||
|
||||
# MD037 - Spaces inside emphasis markers
|
||||
MD037: true
|
||||
|
||||
# MD049 - Emphasis style
|
||||
MD049:
|
||||
style: "asterisk" # Use *text* not _text_
|
||||
|
||||
# MD050 - Strong style
|
||||
MD050:
|
||||
style: "asterisk" # Use **text** not __text__
|
||||
|
||||
# ============================================================================
|
||||
# Block Quotes
|
||||
# ============================================================================
|
||||
|
||||
# MD027 - Multiple spaces after blockquote symbol
|
||||
MD027: true
|
||||
|
||||
# MD028 - Blank line inside blockquote
|
||||
MD028: true
|
||||
|
||||
# ============================================================================
|
||||
# Other
|
||||
# ============================================================================
|
||||
|
||||
# MD018 - No space after hash on atx style heading
|
||||
MD018: true
|
||||
|
||||
# MD019 - Multiple spaces after hash on atx style heading
|
||||
MD019: true
|
||||
|
||||
# MD020 - No space inside hashes on closed atx style heading
|
||||
MD020: true
|
||||
|
||||
# MD021 - Multiple spaces inside hashes on closed atx style heading
|
||||
MD021: true
|
||||
|
||||
# MD044 - Proper names should have the correct capitalization
|
||||
# Disabled: Causes false positives in GitHub URLs containing lowercase project names
|
||||
MD044: false
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# mise tool configuration
|
||||
# https://mise.jdx.dev/
|
||||
|
||||
[tools]
|
||||
# Python 3.11 is required for embedded FreeCAD mode - FreeCAD bundles libpython3.11
|
||||
# Using a different Python version causes ABI incompatibility crashes
|
||||
python = "3.11"
|
||||
uv = "latest"
|
||||
just = "latest"
|
||||
pre-commit = "latest"
|
||||
|
||||
[env]
|
||||
# FreeCAD connection mode:
|
||||
# "xmlrpc" - XML-RPC protocol (recommended, works on all platforms)
|
||||
# "socket" - JSON-RPC socket protocol (alternative)
|
||||
# "embedded" - In-process FreeCAD (Linux only, crashes on macOS/Windows)
|
||||
FREECAD_MODE = "xmlrpc"
|
||||
|
||||
# Path to FreeCAD's lib directory (for embedded mode only)
|
||||
# macOS: "/Applications/FreeCAD.app/Contents/Resources/lib"
|
||||
# Linux: "/usr/lib/freecad/lib"
|
||||
# Windows: "C:/Program Files/FreeCAD/lib"
|
||||
# FREECAD_PATH = ""
|
||||
|
||||
# Connection settings (for xmlrpc and socket modes)
|
||||
FREECAD_SOCKET_HOST = "localhost"
|
||||
FREECAD_SOCKET_PORT = "9876"
|
||||
FREECAD_XMLRPC_PORT = "9875"
|
||||
|
||||
# Execution limits
|
||||
FREECAD_TIMEOUT_MS = "30000"
|
||||
FREECAD_MAX_OUTPUT_SIZE = "1000000"
|
||||
|
||||
[settings]
|
||||
experimental = true
|
||||
@@ -0,0 +1,230 @@
|
||||
# Pre-commit hooks configuration
|
||||
# https://pre-commit.com/
|
||||
|
||||
default_language_version:
|
||||
python: python3.11 # Must match FreeCAD's bundled Python version
|
||||
|
||||
repos:
|
||||
# ==========================================================================
|
||||
# General File Hygiene
|
||||
# ==========================================================================
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude: \.md$ # Allow trailing spaces in markdown for line breaks
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
args: [--unsafe]
|
||||
- id: check-toml
|
||||
- id: check-json
|
||||
- id: check-added-large-files
|
||||
args: [--maxkb=1000]
|
||||
- id: check-merge-conflict
|
||||
- id: check-case-conflict
|
||||
- id: check-symlinks
|
||||
- id: check-executables-have-shebangs
|
||||
- id: check-shebang-scripts-are-executable
|
||||
- id: detect-private-key
|
||||
- id: mixed-line-ending
|
||||
args: [--fix=lf]
|
||||
- id: no-commit-to-branch
|
||||
args: [--branch, main, --branch, master]
|
||||
- id: check-ast # Check Python syntax
|
||||
types: [text]
|
||||
files: \.(py|FCMacro)$
|
||||
|
||||
# ==========================================================================
|
||||
# Python - Linting and Formatting
|
||||
# ==========================================================================
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.4
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --exit-non-zero-on-fix]
|
||||
types_or: [python, text]
|
||||
files: \.(py|FCMacro)$
|
||||
- id: ruff-format
|
||||
types_or: [python, text]
|
||||
files: \.(py|FCMacro)$
|
||||
|
||||
# ==========================================================================
|
||||
# Python - Type Checking
|
||||
# ==========================================================================
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.14.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies:
|
||||
- pydantic>=2.10.0
|
||||
- pydantic-settings>=2.7.0
|
||||
- mcp>=1.25.0
|
||||
# Note: mypy doesn't natively support .FCMacro, so we skip those files
|
||||
# FCMacro files are checked by ruff and bandit instead
|
||||
args: [--config-file=pyproject.toml]
|
||||
|
||||
# ==========================================================================
|
||||
# Python - Security Scanning
|
||||
# ==========================================================================
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.8.0
|
||||
hooks:
|
||||
- id: bandit
|
||||
args: [-c, pyproject.toml, -r, src, macros]
|
||||
additional_dependencies: ["bandit[toml]"]
|
||||
types: [text]
|
||||
files: \.(py|FCMacro)$
|
||||
|
||||
# ==========================================================================
|
||||
# Secrets Detection - Multi-Layer Approach
|
||||
# ==========================================================================
|
||||
|
||||
# Layer 1: Gitleaks - Fast, comprehensive secrets scanner
|
||||
# Scans git history and current files using regex patterns
|
||||
# Config: .gitleaks.toml
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.21.2
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
name: gitleaks (secrets scanner)
|
||||
args: [--config, .gitleaks.toml, --verbose]
|
||||
|
||||
# Layer 2: detect-secrets - Yelp's enterprise-grade secrets detector
|
||||
# Uses baseline file to track known/approved secrets
|
||||
# Config: .secrets.baseline
|
||||
- repo: https://github.com/Yelp/detect-secrets
|
||||
rev: v1.5.0
|
||||
hooks:
|
||||
- id: detect-secrets
|
||||
name: detect-secrets (baseline scan)
|
||||
args:
|
||||
- --baseline
|
||||
- .secrets.baseline
|
||||
- --exclude-files
|
||||
- '\.secrets\.baseline$'
|
||||
- --exclude-files
|
||||
- '\.gitleaks\.toml$'
|
||||
- --exclude-files
|
||||
- 'uv\.lock$'
|
||||
- --exclude-files
|
||||
- 'poetry\.lock$'
|
||||
- --exclude-files
|
||||
- 'package-lock\.json$'
|
||||
|
||||
# Layer 3: TruffleHog - Deep secrets scanner with verification
|
||||
# Verifies secrets are actually valid (e.g., tests AWS keys)
|
||||
- repo: https://github.com/trufflesecurity/trufflehog
|
||||
rev: v3.84.1
|
||||
hooks:
|
||||
- id: trufflehog
|
||||
name: trufflehog (verified secrets scan)
|
||||
args:
|
||||
- --no-update
|
||||
|
||||
# ==========================================================================
|
||||
# Markdown Linting - Comprehensive Configuration
|
||||
# ==========================================================================
|
||||
|
||||
# Primary: markdownlint-cli2 - Comprehensive markdown linter
|
||||
# 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
|
||||
|
||||
# Tertiary: md-toc - Table of contents generator
|
||||
# Automatically updates TOC between <!--TOC--> markers
|
||||
- repo: https://github.com/frnmst/md-toc
|
||||
rev: 9.0.0
|
||||
hooks:
|
||||
- id: md-toc
|
||||
name: md-toc (table of contents)
|
||||
args: ["-p", "github", "-l", "6"] # GitHub parser, max 6 levels
|
||||
files: ^(README|ARCHITECTURE-MCP)\.md$
|
||||
|
||||
# ==========================================================================
|
||||
# Spell Checking
|
||||
# ==========================================================================
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.3.0
|
||||
hooks:
|
||||
- id: codespell
|
||||
additional_dependencies:
|
||||
- tomli
|
||||
args:
|
||||
- --ignore-words
|
||||
- .codespell-ignore-words.txt
|
||||
- --skip
|
||||
- "*.lock,*.json,.secrets.baseline"
|
||||
|
||||
# ==========================================================================
|
||||
# Configuration Validation
|
||||
# ==========================================================================
|
||||
- repo: https://github.com/abravalheri/validate-pyproject
|
||||
rev: v0.23
|
||||
hooks:
|
||||
- id: validate-pyproject
|
||||
|
||||
- repo: https://github.com/python-jsonschema/check-jsonschema
|
||||
rev: 0.30.0
|
||||
hooks:
|
||||
- id: check-github-workflows
|
||||
name: validate GitHub workflows
|
||||
- id: check-dependabot
|
||||
name: validate Dependabot config
|
||||
|
||||
# ==========================================================================
|
||||
# Shell Script Linting
|
||||
# ==========================================================================
|
||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||
rev: v0.10.0.1
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
name: shellcheck (shell linter)
|
||||
args: [--severity=warning]
|
||||
|
||||
# ==========================================================================
|
||||
# Dockerfile Linting
|
||||
# ==========================================================================
|
||||
- repo: https://github.com/hadolint/hadolint
|
||||
rev: v2.13.1-beta
|
||||
hooks:
|
||||
- id: hadolint-docker
|
||||
name: hadolint (Dockerfile linter)
|
||||
|
||||
# ==========================================================================
|
||||
# Commit Message Linting
|
||||
# ==========================================================================
|
||||
- repo: https://github.com/commitizen-tools/commitizen
|
||||
rev: v4.1.0
|
||||
hooks:
|
||||
- id: commitizen
|
||||
name: commitizen (commit format)
|
||||
stages: [commit-msg]
|
||||
|
||||
# ==========================================================================
|
||||
# CI Configuration
|
||||
# ==========================================================================
|
||||
ci:
|
||||
autoupdate_schedule: monthly
|
||||
autoupdate_commit_msg: "chore(deps): update pre-commit hooks"
|
||||
skip:
|
||||
- mypy # Needs dependencies installed
|
||||
- hadolint-docker # Needs Docker
|
||||
- trufflehog # Can be slow in CI
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"plugins_used": [
|
||||
{"name": "ArtifactoryDetector"},
|
||||
{"name": "AWSKeyDetector"},
|
||||
{"name": "AzureStorageKeyDetector"},
|
||||
{"name": "Base64HighEntropyString", "limit": 4.5},
|
||||
{"name": "BasicAuthDetector"},
|
||||
{"name": "CloudantDetector"},
|
||||
{"name": "DiscordBotTokenDetector"},
|
||||
{"name": "GitHubTokenDetector"},
|
||||
{"name": "GitLabTokenDetector"},
|
||||
{"name": "HexHighEntropyString", "limit": 3.0},
|
||||
{"name": "IbmCloudIamDetector"},
|
||||
{"name": "IbmCosHmacDetector"},
|
||||
{"name": "IPPublicDetector"},
|
||||
{"name": "JwtTokenDetector"},
|
||||
{"name": "KeywordDetector", "keyword_exclude": ""},
|
||||
{"name": "MailchimpDetector"},
|
||||
{"name": "NpmDetector"},
|
||||
{"name": "OpenAIDetector"},
|
||||
{"name": "PrivateKeyDetector"},
|
||||
{"name": "PypiTokenDetector"},
|
||||
{"name": "SendGridDetector"},
|
||||
{"name": "SlackDetector"},
|
||||
{"name": "SoftlayerDetector"},
|
||||
{"name": "SquareOAuthDetector"},
|
||||
{"name": "StripeDetector"},
|
||||
{"name": "TelegramBotTokenDetector"},
|
||||
{"name": "TwilioKeyDetector"}
|
||||
],
|
||||
"filters_used": [
|
||||
{"path": "detect_secrets.filters.allowlist.is_line_allowlisted"},
|
||||
{"path": "detect_secrets.filters.common.is_baseline_file", "filename": ".secrets.baseline"},
|
||||
{"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", "min_level": 2},
|
||||
{"path": "detect_secrets.filters.heuristic.is_indirect_reference"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_likely_id_string"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_lock_file"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_potential_uuid"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_sequential_string"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_swagger_file"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_templated_secret"}
|
||||
],
|
||||
"results": {},
|
||||
"generated_at": "2025-01-01T00:00:00Z"
|
||||
}
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"charliermarsh.ruff",
|
||||
"DavidAnson.vscode-markdownlint",
|
||||
"tamasfe.even-better-toml",
|
||||
"redhat.vscode-yaml",
|
||||
"eamodio.gitlens"
|
||||
]
|
||||
}
|
||||
Vendored
+114
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"asyncio",
|
||||
"bandit",
|
||||
"blockchained",
|
||||
"bonninr",
|
||||
"BREP",
|
||||
"brep",
|
||||
"changelog",
|
||||
"codespell",
|
||||
"commitizen",
|
||||
"contextform",
|
||||
"docstrings",
|
||||
"dylib",
|
||||
"fcstd",
|
||||
"freecad",
|
||||
"FreeCAD",
|
||||
"gitleaks",
|
||||
"Gitleaks",
|
||||
"hadolint",
|
||||
"heredoc",
|
||||
"heredocs",
|
||||
"IGES",
|
||||
"iges",
|
||||
"impl",
|
||||
"isinstance",
|
||||
"jango",
|
||||
"justfile",
|
||||
"Justfile",
|
||||
"libpython",
|
||||
"lifespan",
|
||||
"linted",
|
||||
"linter",
|
||||
"linters",
|
||||
"linting",
|
||||
"Makefile",
|
||||
"markdownlint",
|
||||
"Markdownlint",
|
||||
"mcp",
|
||||
"MCP",
|
||||
"mdformat",
|
||||
"mise",
|
||||
"mypy",
|
||||
"neka",
|
||||
"partdesign",
|
||||
"PartDesign",
|
||||
"pydantic",
|
||||
"Pylance",
|
||||
"pyside",
|
||||
"PySide",
|
||||
"pytest",
|
||||
"recomputation",
|
||||
"recompute",
|
||||
"rpath",
|
||||
"ruff",
|
||||
"Ruff",
|
||||
"shellcheck",
|
||||
"SIGSEGV",
|
||||
"sigsegv",
|
||||
"spkane",
|
||||
"subagent",
|
||||
"topods",
|
||||
"trufflehog",
|
||||
"TruffleHog",
|
||||
"uncommitted",
|
||||
"uuidgen",
|
||||
"vertexes",
|
||||
"virtualenv",
|
||||
"XMLRPC",
|
||||
"xmlrpc"
|
||||
],
|
||||
"cSpell.ignorePaths": [
|
||||
".git",
|
||||
"*.lock",
|
||||
"uv.lock",
|
||||
"node_modules",
|
||||
".venv",
|
||||
"__pycache__",
|
||||
"*.pyc",
|
||||
".secrets.baseline",
|
||||
".gitleaks.toml",
|
||||
".codespell-ignore-words.txt"
|
||||
],
|
||||
"spellright.language": [],
|
||||
"spellright.documentTypes": [],
|
||||
"ltex.enabled": false,
|
||||
"editor.suggest.showWords": true,
|
||||
"cSpell.enableFiletypes": [
|
||||
"python",
|
||||
"markdown",
|
||||
"yaml",
|
||||
"toml",
|
||||
"json",
|
||||
"plaintext"
|
||||
],
|
||||
"cSpell.language": "en",
|
||||
"cSpell.allowCompoundWords": true,
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.ruff": "explicit",
|
||||
"source.organizeImports.ruff": "explicit"
|
||||
}
|
||||
},
|
||||
"[markdown]": {
|
||||
"editor.wordWrap": "on",
|
||||
"editor.quickSuggestions": {
|
||||
"comments": "off",
|
||||
"strings": "off",
|
||||
"other": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
+1664
File diff suppressed because it is too large
Load Diff
+336
@@ -0,0 +1,336 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
<!--
|
||||
IMPORTANT: Update this file at the end of any session where repository files are modified.
|
||||
Group changes under: Added, Changed, Deprecated, Removed, Fixed, Security
|
||||
-->
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **GitHub Actions CI/CD workflows**:
|
||||
- `pre-commit.yaml`: Runs all pre-commit hooks on every push and PR
|
||||
- `test.yaml`: Runs unit tests when MCP source files change, integration tests on main branch
|
||||
- `docker.yaml`: Builds multi-arch Docker images, pushes to GHCR on main/tags
|
||||
- `docker-release.yaml`: Builds and pushes to Docker Hub on semantic version tags (v1.2.3)
|
||||
- Multi-arch support (linux/amd64, linux/arm64)
|
||||
- Semantic version tags: `1.2.3`, `1.2`, `1`, and `latest` for stable releases
|
||||
- Prerelease tags (v1.2.3-beta) only get the full version tag
|
||||
- SBOM and provenance attestations for supply chain security
|
||||
- Automatic Docker Hub README sync
|
||||
- `pypi-release.yaml`: Builds and publishes to PyPI on semantic version tags
|
||||
- Validates version in pyproject.toml matches git tag
|
||||
- Tests installation on Ubuntu and macOS before publishing
|
||||
- Stable releases (v1.2.3) go to PyPI, prereleases (v1.2.3-beta) to TestPyPI
|
||||
- Trusted publishing with OIDC (no API tokens needed)
|
||||
- Attaches wheel and sdist to GitHub Release
|
||||
- `macro-test.yaml`: Tests FreeCAD macros when macro files change (validates syntax, runs tests)
|
||||
- Integration tests start FreeCAD headless with MCP bridge automatically
|
||||
- **GitHub issue templates**:
|
||||
- Bug report template with structured fields for connection mode, FreeCAD version, OS, logs
|
||||
- Feature request template with impact assessment and use case sections
|
||||
- Issue template config with links to documentation and discussions
|
||||
- **Dependabot configuration**:
|
||||
- Weekly updates for Python dependencies, GitHub Actions, and Docker base images
|
||||
- Grouped updates for development dependencies
|
||||
- **PyPI package name**: `freecad-robust-mcp` (due to existing `freecad-mcp` on PyPI)
|
||||
- Install via: `pip install freecad-robust-mcp`
|
||||
- CLI command remains `freecad-mcp`
|
||||
- Python import remains `import freecad_mcp`
|
||||
- **Enhanced PyPI and Docker Hub metadata**:
|
||||
- Updated pyproject.toml with expanded classifiers, keywords, and URLs for better discoverability
|
||||
- Added maintainers, Changelog URL, and Docker Hub URL to project metadata
|
||||
- Added `py.typed` marker file for PEP 561 typed package support
|
||||
- Enhanced Dockerfile with full OCI image labels (vendor, authors, documentation, base image)
|
||||
- Added `twine` and `build` to dev dependencies for package validation
|
||||
- **Docker integration test workflow** (`just docker::test`):
|
||||
- Builds the Docker image with a `:test` tag
|
||||
- Automatically starts FreeCAD headless with MCP bridge if not already running
|
||||
- Runs containerized MCP server and verifies communication
|
||||
- Confirms response is from Docker container (Linux OS, Docker detection)
|
||||
- Displays container hostname and raw MCP responses for debugging
|
||||
- Automatically cleans up started FreeCAD process on completion
|
||||
- **New MCP tool `get_mcp_server_environment`**:
|
||||
- Returns environment info about where the MCP server is running
|
||||
- Includes: hostname, os_name, os_version, platform, python_version
|
||||
- Docker detection: `in_docker` boolean and `docker_container_id` (first 12 chars)
|
||||
- Environment variables: FREECAD_MODE, FREECAD_SOCKET_HOST/PORT, FREECAD_XMLRPC_PORT
|
||||
- Useful for verifying which MCP server instance you're connected to
|
||||
- Documented in README.md, CLAUDE.md, and docs/MCP_TOOLS_REFERENCE.md
|
||||
- **Docker support for MCP server**:
|
||||
- New `Dockerfile` with BuildKit optimizations and multi-stage build
|
||||
- Multi-arch support via `docker buildx` (linux/amd64, linux/arm64)
|
||||
- Cache mounts for apt, pip, and uv for faster rebuilds
|
||||
- Non-root user for security
|
||||
- Health check for container orchestration
|
||||
- Environment variables for connection configuration (FREECAD_MODE, FREECAD_SOCKET_HOST, etc.)
|
||||
- `.dockerignore` to exclude development files from build context
|
||||
- Docker just commands: `just docker::build`, `just docker::build-multi`, `just docker::build-push`
|
||||
- **Modular justfile organization**:
|
||||
- Reorganized justfile into modules in `just/` directory for better organization
|
||||
- `just/docker.just` - Docker build and run commands
|
||||
- `just/quality.just` - Code quality and linting commands
|
||||
- `just/testing.just` - Test execution commands
|
||||
- `just/freecad.just` - FreeCAD plugin and macro commands
|
||||
- `just/documentation.just` - Documentation building commands
|
||||
- All existing shortcut commands preserved (e.g., `just test`, `just lint`, `just check`)
|
||||
- New module syntax available: `just docker::build`, `just quality::secrets`, etc.
|
||||
- Use `just --list <module>` to see commands in a specific module
|
||||
- **Improved cut face detection for curved objects in CutObjectForMagnets macro**:
|
||||
- `_find_cut_face_name()` now uses a two-strategy approach:
|
||||
- Strategy 1: Match faces by normal direction (works for flat/planar objects)
|
||||
- Strategy 2: Match faces by proximity to cut plane (works for curved objects like vases)
|
||||
- For curved objects where no face has a matching normal (dot product < 0.99), the function
|
||||
now finds faces whose center lies closest to the cut plane
|
||||
- This fixes the "Could not find cut face" error when cutting vases and other curved shapes at angles
|
||||
- Tested successfully with a vase model cut at 30° angle using a datum plane
|
||||
|
||||
- **Undo support for CutObjectForMagnets macro**:
|
||||
- Each major step is now wrapped in a FreeCAD transaction
|
||||
- Users can undo individual steps via Edit → Undo in the GUI
|
||||
- Transaction steps: Create Bottom Body, Create Top Body, Create Bottom Hole Sketch, Create Top Hole Sketch, Create Bottom Magnet Holes, Create Top Magnet Holes, Hide Original Object
|
||||
- If any step fails, the transaction is aborted to prevent partial state
|
||||
- **VS Code workspace configuration**:
|
||||
- Added `.vscode/settings.json` with cSpell configuration matching codespell pre-commit
|
||||
- Custom dictionary includes all project-specific terms from `.codespell-ignore-words.txt`
|
||||
- Configured Ruff as default Python formatter with format-on-save
|
||||
- Added `.vscode/extensions.json` recommending Code Spell Checker, Ruff, Pylance, etc.
|
||||
- **Integration tests for CutObjectForMagnets macro**:
|
||||
- `test_cut_solid_box_with_partdesign_holes` - Tests cutting solid objects with PartDesign::Hole features
|
||||
- `test_cut_hollow_cylinder_with_partdesign_holes` - Tests cutting hollow objects (vase-shaped) with PartDesign::Hole
|
||||
- `test_boolean_hole_creation_fallback` - Tests the `_create_holes_boolean` fallback method
|
||||
- `test_boolean_vs_partdesign_comparison` - Compares output of both hole creation methods
|
||||
- `test_single_hole` / `test_many_holes` - Edge case tests for hole count variations
|
||||
- Tests are in `tests/integration/test_cut_object_for_magnets.py` and run via `just test-integration`
|
||||
- **Status bar display for MCP bridge in GUI mode**:
|
||||
- Shows "🔌 MCP Bridge running (XML-RPC:9875) | waiting for connections..." when idle
|
||||
- Shows "🔌 MCP Bridge active (XML-RPC:9875) | X requests | last: Ys ago" after activity
|
||||
- Updates every 5 seconds to show current status
|
||||
- Automatically clears when bridge is stopped
|
||||
- Initial MCP server implementation for FreeCAD integration
|
||||
- Bridge architecture supporting multiple connection modes:
|
||||
- Embedded mode (headless FreeCAD)
|
||||
- Socket mode (JSON-RPC)
|
||||
- XML-RPC mode (neka-nat compatible)
|
||||
- MCP tools for FreeCAD operations:
|
||||
- Document management (create, open, save, close)
|
||||
- Object manipulation (create, modify, delete)
|
||||
- PartDesign workflow (sketches, pads, pockets, fillets)
|
||||
- Import/export (STEP, STL, 3MF, OBJ, IGES)
|
||||
- Macro management
|
||||
- View and screenshot capture
|
||||
- `export_3mf` - Export objects to 3MF format (modern 3D printing format with color/material support)
|
||||
- Additional primitive creation tools:
|
||||
- `create_cone` - Create cone primitives with configurable radii
|
||||
- `create_torus` - Create torus (donut) primitives
|
||||
- `create_wedge` - Create wedge/ramp primitives
|
||||
- `create_helix` - Create helix curves for threads and springs
|
||||
- Transform and manipulation tools:
|
||||
- `scale_object` - Scale objects uniformly or non-uniformly
|
||||
- `rotate_object` - Rotate objects around arbitrary axes
|
||||
- `copy_object` - Create copies with optional offset
|
||||
- `mirror_object` - Mirror objects across planes (XY, XZ, YZ)
|
||||
- Selection management tools:
|
||||
- `get_selection` - Get current selection with sub-elements
|
||||
- `set_selection` - Programmatically select objects
|
||||
- `clear_selection` - Clear all selections
|
||||
- Advanced PartDesign tools:
|
||||
- `revolution_sketch` - Create revolution solids from sketches
|
||||
- `groove_sketch` - Create subtractive revolutions
|
||||
- `create_hole` - Create parametric holes with threading support (ISO, UNC, UNF)
|
||||
- `linear_pattern` - Create linear patterns of features
|
||||
- `polar_pattern` - Create circular patterns of features
|
||||
- `mirrored_feature` - Mirror PartDesign features
|
||||
- `loft_sketches` - Create lofts through multiple profiles
|
||||
- `sweep_sketch` - Sweep profiles along paths
|
||||
- Additional sketch geometry tools:
|
||||
- `add_sketch_line` - Add lines with construction mode option
|
||||
- `add_sketch_arc` - Add arcs with center/radius/angles
|
||||
- `add_sketch_point` - Add points for hole centers
|
||||
- View and document control tools:
|
||||
- `zoom_in` / `zoom_out` - View zoom controls
|
||||
- `set_camera_position` - Set camera position and look-at point
|
||||
- `undo` / `redo` - Document undo/redo operations
|
||||
- `get_undo_redo_status` - Query undo/redo state
|
||||
- `recompute` - Force document recomputation
|
||||
- `get_console_log` - Get console messages, warnings, errors
|
||||
- Parts library tools:
|
||||
- `list_parts_library` - List available library parts
|
||||
- `insert_part_from_library` - Insert parts from library with positioning
|
||||
- MCP resources exposing FreeCAD state:
|
||||
- Version and status information
|
||||
- Document and object listings
|
||||
- Workbench information
|
||||
- Console output
|
||||
- MCP prompts for guided workflows:
|
||||
- Part design assistance
|
||||
- Export/import guidance
|
||||
- Shape analysis
|
||||
- Macro development help
|
||||
- Troubleshooting guides
|
||||
- Comprehensive pre-commit hook configuration:
|
||||
- Ruff for linting and formatting
|
||||
- MyPy for type checking
|
||||
- Bandit for security scanning
|
||||
- Multi-layer secrets detection (Gitleaks, detect-secrets, TruffleHog)
|
||||
- Markdownlint and mdformat for documentation
|
||||
- Codespell for spell checking
|
||||
- Project documentation:
|
||||
- CLAUDE.md with AI assistant guidelines
|
||||
- ARCHITECTURE.md with system design
|
||||
- README.md with usage instructions
|
||||
|
||||
### Changed
|
||||
|
||||
- **Reorganized README.md structure**:
|
||||
- Split into "For Users" and "For Developers" top-level sections
|
||||
- Each section has MCP Server and FreeCAD Macros subsections
|
||||
- Added Docker installation and Claude Code configuration for containerized usage
|
||||
- pip installation now shown as primary option for users
|
||||
- mise/just shown as alternative for developers
|
||||
- **Improved docs/index.md landing page**:
|
||||
- Now properly links to all documentation files in the repo
|
||||
- Added documentation table with descriptions
|
||||
- Updated quick start, features, and connection modes sections
|
||||
- CLAUDE.md now documents `uv` package manager usage and `uv run` requirement for Python tools
|
||||
- Added proper MIT license attribution to files inspired by neka-nat/freecad-mcp:
|
||||
- `src/freecad_mcp/bridge/xmlrpc.py` - XML-RPC protocol design
|
||||
- `src/freecad_mcp/freecad_plugin/server.py` - Queue-based thread safety pattern
|
||||
- **Improved error messaging for connection failures**:
|
||||
- XML-RPC bridge now provides detailed instructions when FreeCAD plugin is not running
|
||||
- Error message explains how to start the MCP bridge in FreeCAD
|
||||
- References the new `just install-macro` command for easy setup
|
||||
- **New justfile commands for FreeCAD integration**:
|
||||
- `just install-macro` - Installs StartMCPBridge.FCMacro with dynamic project paths
|
||||
- `just uninstall-macro` - Removes the installed macro
|
||||
- `just install-freecad-plugin` - Installs full plugin to FreeCAD Mod directory
|
||||
- `just uninstall-freecad-plugin` - Removes the installed plugin
|
||||
- `just run-headless` - Launches FreeCAD in headless mode with MCP bridge
|
||||
- `just run-headless-custom <path>` - Launch with custom FreeCADCmd path
|
||||
- `just run-gui` - Launches FreeCAD GUI with MCP bridge auto-started
|
||||
- `just run-gui-custom <path>` - Launch GUI with custom FreeCAD path
|
||||
- Supports macOS, Linux, and Windows paths automatically
|
||||
- **Headless mode support for FreeCAD plugin**:
|
||||
- Plugin now detects when Qt is unavailable and uses thread-based queue processing
|
||||
- New `run_forever()` method for running in headless/console mode
|
||||
- New `headless_server.py` script for easy headless startup
|
||||
- Note: Screenshots and view features are not available in headless mode
|
||||
- **Improved error handling for GUI-dependent tools**:
|
||||
- All view tools now check `FreeCAD.GuiUp` and return clear error messages in headless mode
|
||||
- Tools return `{"success": False, "error": "..."}` instead of raising exceptions
|
||||
- Affected tools: `set_object_visibility`, `set_display_mode`, `set_object_color`,
|
||||
`zoom_in`, `zoom_out`, `set_camera_position`, `get_screenshot`
|
||||
- Non-GUI tools (`undo`, `redo`, `recompute`) now also return structured error responses
|
||||
- **Updated documentation for connection modes**:
|
||||
- README.md now documents all three modes: `xmlrpc`, `socket`, `embedded`
|
||||
- Changed default recommended mode from `embedded` to `xmlrpc` (works on all platforms)
|
||||
- Added `FREECAD_XMLRPC_PORT` environment variable documentation
|
||||
- Documented platform limitations (embedded mode only works on Linux)
|
||||
- Updated Python version requirement from 3.12+ to 3.11 (FreeCAD ABI compatibility)
|
||||
- ARCHITECTURE.md deployment modes updated to reflect current implementation
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Justfile module working directory issue**:
|
||||
- Fixed `just freecad::run-headless` and other FreeCAD commands failing with wrong path
|
||||
- Issue: `$(pwd)` in module recipes returned `just/` directory instead of project root
|
||||
- Solution: All module files now use `project_root := justfile_directory()` variable
|
||||
- Added documentation in CLAUDE.md about justfile working directory behavior in modules
|
||||
- **Cut face detection for re-cutting already-cut objects in CutObjectForMagnets macro**:
|
||||
- When cutting an object that was previously cut, the macro now correctly identifies the NEW cut face
|
||||
- Previously, the algorithm only checked face normals but not proximity to the cut plane
|
||||
- This caused it to potentially select an OLD cut face from a previous operation
|
||||
- New algorithm uses three strategies with combined normal + distance checks:
|
||||
1. Planar faces with exact normal match AND close to cut plane (ideal case)
|
||||
2. Any faces with good normal match close to cut plane (for curved objects)
|
||||
3. Fallback to best normal match with warning (for edge cases)
|
||||
- **Sketch attachment failure in CutObjectForMagnets macro**:
|
||||
- Changed `_find_cut_face_name()` to use `body.BaseFeature` instead of `body.Tip`
|
||||
- Changed `_create_hole_sketch()` to attach sketches to BaseFeature instead of Tip
|
||||
- Root cause: When re-running the macro on a failed state, `body.Tip` was the broken
|
||||
`MagnetHoles` feature (with 0 faces) instead of the solid `BaseFeature`
|
||||
- This caused the macro to select non-planar faces (Cone, Toroid) instead of the
|
||||
actual planar cut face, resulting in "Invalid" sketch state with `FlatFace` MapMode
|
||||
- Now correctly finds Face4 (the planar cut face with dot=1.0) instead of Face81 (Cone)
|
||||
- **Python version mismatch crash** - Embedded FreeCAD mode was crashing with SIGSEGV
|
||||
- FreeCAD bundles `libpython3.11.dylib` and requires Python 3.11 for ABI compatibility
|
||||
- Using Python 3.12+ caused `EXC_BAD_ACCESS` when loading FreeCAD.so (calling `PySys_GetObject` from wrong Python version)
|
||||
- Changed `.mise.toml` to use Python 3.11 and updated `pyproject.toml` accordingly
|
||||
- **Embedded mode crashes on macOS** - Even with correct Python version, embedded mode fails
|
||||
- FreeCAD's `FreeCAD.so` links to `@rpath/libpython3.11.dylib` which conflicts with external Python interpreters
|
||||
- Documented that embedded mode only works on Linux; macOS/Windows must use `xmlrpc` or `socket` mode
|
||||
- Updated `.mcp.json` to use `xmlrpc` mode by default
|
||||
- Added FreeCAD connection modes documentation to CLAUDE.md
|
||||
- Server startup crash with `AttributeError: 'FastMCP' object has no attribute 'on_startup'`
|
||||
- Migrated from deprecated `mcp.on_startup()`/`on_shutdown()` hooks to the modern `lifespan` async context manager pattern
|
||||
- This fix is required for compatibility with MCP SDK 1.25.0+
|
||||
- Pre-commit hook compatibility issues:
|
||||
- Ruff UP038: Updated isinstance calls to use modern union syntax
|
||||
- MyPy: Added type ignore comments for FastMCP dynamic attributes
|
||||
- Codespell: Added FreeCAD-specific terms to ignore list
|
||||
- Markdownlint/mdformat: Added mdformat-simple-breaks plugin for consistent horizontal rules
|
||||
- Justfile commands now use `uv run` prefix for all Python tools (pytest, ruff, mypy, etc.)
|
||||
- **Headless server import error** - `just run-headless` was failing with `'freecad_mcp' is not a package`
|
||||
- The `freecad_mcp/__init__.py` imports the MCP SDK which isn't available in FreeCAD's Python environment
|
||||
- Fixed by importing `FreecadMCPPlugin` directly from the module file instead of through the package hierarchy
|
||||
- Added explicit `flush=True` to print statements for immediate output in FreeCAD's buffered stdout
|
||||
- **Headless mode queue processor not running** - XML-RPC `execute` calls would hang indefinitely
|
||||
- The code was checking for Qt (PySide) availability to determine GUI vs headless mode
|
||||
- PySide6 is available in FreeCAD headless mode, but Qt timers don't fire without an event loop
|
||||
- Fixed by using `FreeCAD.GuiUp` to correctly detect headless mode and start the queue processor thread
|
||||
- **Renamed CutWithConnectors macro to CutObjectForMagnets**:
|
||||
- Macro file renamed from `CutWithConnectors.FCMacro` to `CutObjectForMagnets.FCMacro`
|
||||
- Directory renamed from `Cut_with_Connectors` to `Cut_Object_for_Magnets`
|
||||
- Updated class names: `CutWithConnectorsDialog` → `CutObjectForMagnetsDialog`
|
||||
- Updated dialog title and UI labels to reference "magnets" instead of "connectors"
|
||||
- Updated justfile commands to reference new file paths
|
||||
- All functionality preserved - just a naming update for clarity
|
||||
- **CutObjectForMagnets macro - hole placement improvements**:
|
||||
- Changed from spacing-based to count-based hole placement (default: 6 holes)
|
||||
- Holes now distributed evenly along the perimeter of the cut face
|
||||
- Both halves now have identical hole positions that align when joined
|
||||
- Fixed cut face detection to find the correct face at the cut plane (was finding original object faces)
|
||||
- Fixed hole positioning for hollow objects (ring-shaped cross-sections)
|
||||
- Safety check correctly rejects holes that would penetrate inner walls on thin-walled objects
|
||||
- Added smart hole repositioning: when a hole fails the safety check, the macro tries to find a nearby safe position by:
|
||||
- Moving further inward from the edge (1.5x, 2x, 2.5x, 3x inset)
|
||||
- Moving along the perimeter to find a thicker section (±5%, ±10%, ±15%, ±20%)
|
||||
- Hole validation now checks BOTH parts simultaneously to ensure holes align perfectly
|
||||
- Added minimum hole spacing check: holes must be at least 2x diameter apart (one hole width between them)
|
||||
- Automatically reduces hole count when requested holes would overlap
|
||||
- Updated default values: diameter=3mm, depth=3mm, holes=6
|
||||
- Added dual clearance system with preferred and minimum values:
|
||||
- "Edge Clearance (Preferred)": default 2mm - used for initial hole placement
|
||||
- "Edge Clearance (Minimum)": default 0.5mm - used as fallback during repositioning
|
||||
- Smart repositioning now tries clearances from preferred down to minimum before moving holes
|
||||
- This keeps holes in ideal positions when possible, only moving closer to edges when necessary
|
||||
- Dialog now shows body name and type being cut
|
||||
- Smart plane detection: selecting both object and plane auto-sets the plane as cut plane
|
||||
- Added object selection combo box: users can now change the object to cut in the dialog (not just from initial selection)
|
||||
- **CutObjectForMagnets macro - PartDesign::Hole implementation (parametric holes)**:
|
||||
- Holes now created as native `PartDesign::Hole` features instead of Part boolean operations
|
||||
- Cut halves created as `PartDesign::Body` objects using `PartDesign::BaseFeature`
|
||||
- Hole centers defined via `Sketcher::SketchObject` attached to cut face
|
||||
- Benefits of new approach:
|
||||
- Holes appear in the feature tree and can be edited after creation
|
||||
- Change hole diameter/depth by modifying the Hole feature properties
|
||||
- Sketch shows hole center points for visualization
|
||||
- Full PartDesign parametric workflow integration
|
||||
- New helper methods added:
|
||||
- `_create_body_from_shape()` - Wraps Part::Shape in PartDesign::Body
|
||||
- `_find_cut_face_name()` - Locates cut face by normal direction
|
||||
- `_world_to_sketch_coords()` - Transforms world coords to sketch-local 2D
|
||||
- `_create_hole_sketch()` - Creates sketch with points at hole centers
|
||||
- `_create_hole_feature()` - Creates PartDesign::Hole from sketch
|
||||
- Original boolean hole method kept as `_create_holes_boolean()` for potential fallback
|
||||
- FreeCAD 1.0+ API compatibility fixes:
|
||||
- Fixed `_create_body_from_shape()` to use `body.BaseFeature = feature` property instead of `body.newObject("PartDesign::BaseFeature")`
|
||||
- Fixed sketch attachment to use `AttachmentSupport` property instead of deprecated `Support`
|
||||
- Fixed `_create_hole_feature()` to use `DepthType = "Dimension"` (string) and `HoleCutType = "None"` instead of numeric values
|
||||
- Tested successfully with solid objects (box) and hollow objects (vase-shaped cylinder)
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# FreeCAD MCP Server Dockerfile
|
||||
# Multi-stage build with BuildKit optimizations for multi-arch support
|
||||
#
|
||||
# Build:
|
||||
# docker build -t freecad-mcp .
|
||||
#
|
||||
# Build multi-arch:
|
||||
# docker buildx build --platform linux/amd64,linux/arm64 -t freecad-mcp .
|
||||
#
|
||||
# Run:
|
||||
# docker run --rm -i freecad-mcp
|
||||
|
||||
# =============================================================================
|
||||
# Stage 1: Builder - Install dependencies and build the package
|
||||
# =============================================================================
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
# Install build dependencies
|
||||
# hadolint ignore=DL3008
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set up working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install uv for fast dependency management
|
||||
# Using pip cache mount for faster rebuilds
|
||||
# hadolint ignore=DL3013
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --no-compile uv
|
||||
|
||||
# Copy only dependency files first for better layer caching
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src/ ./src/
|
||||
|
||||
# Create virtual environment and install dependencies
|
||||
# Using uv cache mount for faster rebuilds
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv venv /opt/venv && \
|
||||
. /opt/venv/bin/activate && \
|
||||
uv pip install --no-compile .
|
||||
|
||||
# =============================================================================
|
||||
# Stage 2: Runtime - Minimal image for running the server
|
||||
# =============================================================================
|
||||
FROM python:3.11-slim AS runtime
|
||||
|
||||
# Labels for container metadata (OCI Image Spec)
|
||||
# Note: version, revision, and created are set dynamically in CI/CD workflows
|
||||
LABEL org.opencontainers.image.title="FreeCAD MCP Server" \
|
||||
org.opencontainers.image.description="MCP (Model Context Protocol) server for FreeCAD integration with AI assistants" \
|
||||
org.opencontainers.image.url="https://github.com/spkane/freecad-mcp" \
|
||||
org.opencontainers.image.source="https://github.com/spkane/freecad-mcp" \
|
||||
org.opencontainers.image.documentation="https://github.com/spkane/freecad-mcp#readme" \
|
||||
org.opencontainers.image.licenses="MIT" \
|
||||
org.opencontainers.image.vendor="Sean P. Kane" \
|
||||
org.opencontainers.image.authors="Sean P. Kane <spkane@gmail.com>" \
|
||||
org.opencontainers.image.base.name="python:3.11-slim"
|
||||
|
||||
# Create non-root user for security
|
||||
RUN groupadd --gid 1000 mcpuser && \
|
||||
useradd --uid 1000 --gid 1000 --shell /bin/bash --create-home mcpuser
|
||||
|
||||
# Copy virtual environment from builder
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
# Set environment variables
|
||||
ENV PATH="/opt/venv/bin:$PATH" \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
# Default to xmlrpc mode (requires FreeCAD running externally)
|
||||
FREECAD_MODE="xmlrpc" \
|
||||
FREECAD_SOCKET_HOST="host.docker.internal" \
|
||||
FREECAD_SOCKET_PORT="9876" \
|
||||
FREECAD_XMLRPC_PORT="9875" \
|
||||
FREECAD_TIMEOUT_MS="30000"
|
||||
|
||||
# Switch to non-root user
|
||||
USER mcpuser
|
||||
WORKDIR /home/mcpuser
|
||||
|
||||
# Health check - verify the server can start
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import freecad_mcp; print('ok')" || exit 1
|
||||
|
||||
# Default command - run the MCP server in stdio mode
|
||||
ENTRYPOINT ["freecad-mcp"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,699 @@
|
||||
# FreeCAD Tools and MCP Server
|
||||
|
||||
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. Also includes standalone FreeCAD macros for common tasks.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
<!--TOC-->
|
||||
|
||||
- [FreeCAD Tools and MCP Server](#freecad-tools-and-mcp-server)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Features](#features)
|
||||
- [Requirements](#requirements)
|
||||
- [For Users](#for-users)
|
||||
- [MCP Server](#mcp-server)
|
||||
- [Installation](#installation)
|
||||
- [Using pip (recommended)](#using-pip-recommended)
|
||||
- [Using mise and just (from source)](#using-mise-and-just-from-source)
|
||||
- [Using Docker](#using-docker)
|
||||
- [Configuration](#configuration)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Connection Modes](#connection-modes)
|
||||
- [MCP Client Configuration](#mcp-client-configuration)
|
||||
- [Usage](#usage)
|
||||
- [Starting the MCP Bridge in FreeCAD](#starting-the-mcp-bridge-in-freecad)
|
||||
- [Running Modes](#running-modes)
|
||||
- [XML-RPC Mode (Recommended)](#xml-rpc-mode-recommended)
|
||||
- [Socket Mode (JSON-RPC)](#socket-mode-json-rpc)
|
||||
- [Headless Mode](#headless-mode)
|
||||
- [Embedded Mode (Linux Only)](#embedded-mode-linux-only)
|
||||
- [Available Tools](#available-tools)
|
||||
- [Execution & Debugging (5 tools)](#execution--debugging-5-tools)
|
||||
- [Document Management (7 tools)](#document-management-7-tools)
|
||||
- [Object Creation - Primitives (8 tools)](#object-creation---primitives-8-tools)
|
||||
- [Object Management (12 tools)](#object-management-12-tools)
|
||||
- [PartDesign - Sketching (14 tools)](#partdesign---sketching-14-tools)
|
||||
- [PartDesign - Patterns & Edges (5 tools)](#partdesign---patterns--edges-5-tools)
|
||||
- [View & Display (11 tools)](#view--display-11-tools)
|
||||
- [Undo/Redo (3 tools)](#undoredo-3-tools)
|
||||
- [Export/Import (7 tools)](#exportimport-7-tools)
|
||||
- [Macro Management (6 tools)](#macro-management-6-tools)
|
||||
- [Parts Library (2 tools)](#parts-library-2-tools)
|
||||
- [FreeCAD Macros](#freecad-macros)
|
||||
- [CutObjectForMagnets](#cutobjectformagnets)
|
||||
- [For Developers](#for-developers)
|
||||
- [MCP Server Development](#mcp-server-development)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Initial Setup](#initial-setup)
|
||||
- [MCP Client Configuration (Development)](#mcp-client-configuration-development)
|
||||
- [Development Workflow](#development-workflow)
|
||||
- [Running FreeCAD with the MCP Bridge](#running-freecad-with-the-mcp-bridge)
|
||||
- [GUI Mode (recommended for development)](#gui-mode-recommended-for-development)
|
||||
- [Headless Mode (for automation/CI)](#headless-mode-for-automationci)
|
||||
- [Running Tests](#running-tests)
|
||||
- [Code Quality](#code-quality)
|
||||
- [Macro Development](#macro-development)
|
||||
- [StartMCPBridge Macro](#startmcpbridge-macro)
|
||||
- [CutObjectForMagnets Macro](#cutobjectformagnets-macro)
|
||||
- [Architecture](#architecture)
|
||||
- [License](#license)
|
||||
|
||||
<!--TOC-->
|
||||
|
||||
## 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 via MCP
|
||||
- **Standalone Macros**: Useful FreeCAD macros that work independently of the MCP server
|
||||
|
||||
## Requirements
|
||||
|
||||
- [FreeCAD](https://www.freecadweb.org/) 0.21+ or 1.0+
|
||||
- Python 3.11 (required for FreeCAD ABI compatibility)
|
||||
|
||||
---
|
||||
|
||||
## For Users
|
||||
|
||||
This section covers installation and usage for end users who want to use the MCP server with AI assistants or the standalone FreeCAD macros.
|
||||
|
||||
## MCP Server
|
||||
|
||||
### Installation
|
||||
|
||||
#### Using pip (recommended)
|
||||
|
||||
```bash
|
||||
pip install freecad-robust-mcp
|
||||
```
|
||||
|
||||
#### Using mise and just (from source)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/spkane/freecad-mcp.git
|
||||
cd freecad-mcp
|
||||
mise trust
|
||||
mise install
|
||||
just setup
|
||||
```
|
||||
|
||||
#### Using Docker
|
||||
|
||||
Run the MCP server in a container. This is useful for isolated environments or when you don't want to install Python dependencies on your host.
|
||||
|
||||
```bash
|
||||
# Pull from Docker Hub (when published)
|
||||
docker pull spkane/freecad-mcp
|
||||
|
||||
# Or build locally
|
||||
git clone https://github.com/spkane/freecad-mcp.git
|
||||
cd freecad-mcp
|
||||
docker build -t freecad-mcp .
|
||||
|
||||
# Or use just commands (if you have mise/just installed)
|
||||
just docker::build # Build for local architecture
|
||||
just docker::build-multi # Build multi-arch (amd64 + arm64)
|
||||
```
|
||||
|
||||
**Note:** The containerized MCP server only supports `xmlrpc` and `socket` modes since FreeCAD runs on your host machine (not in the container). The container connects to FreeCAD via `host.docker.internal`.
|
||||
|
||||
### Configuration
|
||||
|
||||
#### 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
|
||||
|
||||
| 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) |
|
||||
|
||||
**Note:** Embedded mode crashes on macOS because FreeCAD's `FreeCAD.so` links to `@rpath/libpython3.11.dylib`, which conflicts with external Python interpreters. Use `xmlrpc` or `socket` mode on macOS and Windows.
|
||||
|
||||
#### MCP Client Configuration
|
||||
|
||||
Add something like the following to your MCP client settings. For Claude Code, this is `~/.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-mcp", "freecad-mcp"],
|
||||
"env": {
|
||||
"FREECAD_MODE": "xmlrpc"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If using Docker:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"freecad": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run", "--rm", "-i",
|
||||
"--add-host=host.docker.internal:host-gateway",
|
||||
"-e", "FREECAD_MODE=xmlrpc",
|
||||
"-e", "FREECAD_SOCKET_HOST=host.docker.internal",
|
||||
"freecad-mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Docker configuration notes:**
|
||||
|
||||
- `--rm` removes the container after it exits
|
||||
- `-i` keeps stdin open for MCP communication
|
||||
- `--add-host=host.docker.internal:host-gateway` allows the container to connect to FreeCAD on your host (Linux only; macOS/Windows have this built-in)
|
||||
- `FREECAD_SOCKET_HOST=host.docker.internal` tells the MCP server to connect to FreeCAD on your host machine
|
||||
|
||||
### Usage
|
||||
|
||||
#### Starting the MCP Bridge in FreeCAD
|
||||
|
||||
Before your AI assistant can connect, you need to start the MCP bridge inside FreeCAD:
|
||||
|
||||
1. Install the bridge macro (one time):
|
||||
|
||||
```bash
|
||||
# If installed from source:
|
||||
just install-bridge-macro
|
||||
|
||||
# Or manually copy from the macros/Start_MCP_Bridge/ directory
|
||||
```
|
||||
|
||||
1. Start FreeCAD
|
||||
|
||||
1. Run the macro: **Macro -> Macros -> StartMCPBridge -> Execute**
|
||||
|
||||
1. You should see in the FreeCAD console:
|
||||
|
||||
```text
|
||||
MCP Bridge started!
|
||||
- XML-RPC: localhost:9875
|
||||
- Socket: localhost:9876
|
||||
```
|
||||
|
||||
1. Start/restart your MCP client (Claude Code, etc.) - it will connect automatically
|
||||
|
||||
#### Running Modes
|
||||
|
||||
##### XML-RPC Mode (Recommended)
|
||||
|
||||
Connects to a running FreeCAD instance via XML-RPC. Works on all platforms.
|
||||
|
||||
```bash
|
||||
FREECAD_MODE=xmlrpc freecad-mcp
|
||||
```
|
||||
|
||||
##### Socket Mode (JSON-RPC)
|
||||
|
||||
Connects via JSON-RPC socket. Works on all platforms.
|
||||
|
||||
```bash
|
||||
FREECAD_MODE=socket freecad-mcp
|
||||
```
|
||||
|
||||
##### Headless Mode
|
||||
|
||||
Run FreeCAD in console mode without GUI. Useful for automation.
|
||||
|
||||
```bash
|
||||
# If installed from source:
|
||||
just run-headless
|
||||
```
|
||||
|
||||
**Note:** Screenshot and view features are not available in headless mode.
|
||||
|
||||
##### Embedded Mode (Linux Only)
|
||||
|
||||
Runs FreeCAD in-process. **Only works on Linux** - crashes on macOS/Windows.
|
||||
|
||||
```bash
|
||||
FREECAD_MODE=embedded freecad-mcp
|
||||
```
|
||||
|
||||
### Available Tools
|
||||
|
||||
The MCP server provides **83 tools** organized into categories. Tools marked with **GUI** require FreeCAD to be running in GUI mode; they will return an error in headless mode.
|
||||
|
||||
#### Execution & Debugging (5 tools)
|
||||
|
||||
| Tool | Description | Mode |
|
||||
| ---------------------------- | ------------------------------------------------------ | ---- |
|
||||
| `execute_python` | Execute arbitrary Python code in FreeCAD's context | All |
|
||||
| `get_freecad_version` | Get FreeCAD version, build date, and Python version | All |
|
||||
| `get_connection_status` | Check MCP bridge connection status and latency | All |
|
||||
| `get_console_output` | Get recent FreeCAD console output (up to N lines) | All |
|
||||
| `get_mcp_server_environment` | Get MCP server environment (OS, hostname, Docker info) | All |
|
||||
|
||||
#### Document Management (7 tools)
|
||||
|
||||
| Tool | Description | Mode |
|
||||
| --------------------- | ----------------------------------------- | ---- |
|
||||
| `list_documents` | List all open documents with metadata | All |
|
||||
| `get_active_document` | Get information about the active document | All |
|
||||
| `create_document` | Create a new FreeCAD document | All |
|
||||
| `open_document` | Open an existing .FCStd file | All |
|
||||
| `save_document` | Save a document to disk | All |
|
||||
| `close_document` | Close a document (with optional save) | All |
|
||||
| `recompute_document` | Force recomputation of all objects | All |
|
||||
|
||||
#### Object Creation - Primitives (8 tools)
|
||||
|
||||
| Tool | Description | Mode |
|
||||
| ----------------- | -------------------------------------------------- | ---- |
|
||||
| `create_object` | Create a generic FreeCAD object by type ID | All |
|
||||
| `create_box` | Create a Part::Box with length, width, height | All |
|
||||
| `create_cylinder` | Create a Part::Cylinder with radius, height, angle | All |
|
||||
| `create_sphere` | Create a Part::Sphere with radius | All |
|
||||
| `create_cone` | Create a Part::Cone with two radii and height | All |
|
||||
| `create_torus` | Create a Part::Torus (donut) with radii and angles | All |
|
||||
| `create_wedge` | Create a Part::Wedge (tapered box) | All |
|
||||
| `create_helix` | Create a Part::Helix curve for sweeps and threads | All |
|
||||
|
||||
#### Object Management (12 tools)
|
||||
|
||||
| Tool | Description | Mode |
|
||||
| ------------------- | -------------------------------------------------- | ---- |
|
||||
| `list_objects` | List all objects in a document | All |
|
||||
| `inspect_object` | Get detailed object info (properties, shape, etc.) | All |
|
||||
| `edit_object` | Modify properties of an existing object | All |
|
||||
| `delete_object` | Delete an object from a document | All |
|
||||
| `set_placement` | Set object position and rotation | All |
|
||||
| `scale_object` | Scale an object uniformly or non-uniformly | All |
|
||||
| `rotate_object` | Rotate an object around an axis | All |
|
||||
| `copy_object` | Create a copy of an object | All |
|
||||
| `mirror_object` | Mirror an object across a plane (XY, XZ, YZ) | All |
|
||||
| `boolean_operation` | Fuse, cut, or intersect objects | All |
|
||||
| `get_selection` | Get currently selected objects | GUI |
|
||||
| `set_selection` | Select specific objects by name | GUI |
|
||||
| `clear_selection` | Clear all selections | GUI |
|
||||
|
||||
#### PartDesign - Sketching (14 tools)
|
||||
|
||||
| Tool | Description | Mode |
|
||||
| ------------------------ | ----------------------------------------------- | ---- |
|
||||
| `create_partdesign_body` | Create a PartDesign::Body container | All |
|
||||
| `create_sketch` | Create a sketch on a plane or face | All |
|
||||
| `add_sketch_rectangle` | Add a rectangle to a sketch | All |
|
||||
| `add_sketch_circle` | Add a circle to a sketch | All |
|
||||
| `add_sketch_line` | Add a line (with optional construction flag) | All |
|
||||
| `add_sketch_arc` | Add an arc by center, radius, and angles | All |
|
||||
| `add_sketch_point` | Add a point (useful for hole centers) | All |
|
||||
| `pad_sketch` | Extrude a sketch (additive) | All |
|
||||
| `pocket_sketch` | Cut into solid using a sketch (subtractive) | All |
|
||||
| `revolution_sketch` | Revolve a sketch around an axis (additive) | All |
|
||||
| `groove_sketch` | Revolve a sketch around an axis (subtractive) | All |
|
||||
| `create_hole` | Create parametric holes with optional threading | All |
|
||||
| `loft_sketches` | Create a loft through multiple sketches | All |
|
||||
| `sweep_sketch` | Sweep a profile along a spine path | All |
|
||||
|
||||
#### PartDesign - Patterns & Edges (5 tools)
|
||||
|
||||
| Tool | Description | Mode |
|
||||
| ------------------ | ------------------------------------------ | ---- |
|
||||
| `linear_pattern` | Create linear pattern of a feature | All |
|
||||
| `polar_pattern` | Create polar/circular pattern of a feature | All |
|
||||
| `mirrored_feature` | Mirror a feature across a plane | All |
|
||||
| `fillet_edges` | Add fillets (rounded edges) | All |
|
||||
| `chamfer_edges` | Add chamfers (beveled edges) | All |
|
||||
|
||||
#### View & Display (11 tools)
|
||||
|
||||
| Tool | Description | Mode |
|
||||
| ----------------------- | ----------------------------------------------- | ---- |
|
||||
| `get_screenshot` | Capture a screenshot of the 3D view | GUI |
|
||||
| `set_view_angle` | Set camera to standard views (Front, Top, etc.) | GUI |
|
||||
| `fit_all` | Zoom to fit all objects in view | GUI |
|
||||
| `zoom_in` | Zoom in by a factor | GUI |
|
||||
| `zoom_out` | Zoom out by a factor | GUI |
|
||||
| `set_camera_position` | Set camera position and look-at point | GUI |
|
||||
| `set_object_visibility` | Show/hide objects | GUI |
|
||||
| `set_display_mode` | Set display mode (Shaded, Wireframe, etc.) | GUI |
|
||||
| `set_object_color` | Set object color as RGB values | GUI |
|
||||
| `list_workbenches` | List available FreeCAD workbenches | All |
|
||||
| `activate_workbench` | Switch to a different workbench | All |
|
||||
|
||||
#### Undo/Redo (3 tools)
|
||||
|
||||
| Tool | Description | Mode |
|
||||
| ---------------------- | ---------------------------------- | ---- |
|
||||
| `undo` | Undo the last operation | All |
|
||||
| `redo` | Redo a previously undone operation | All |
|
||||
| `get_undo_redo_status` | Get available undo/redo operations | All |
|
||||
|
||||
#### Export/Import (7 tools)
|
||||
|
||||
| Tool | Description | Mode |
|
||||
| ------------- | ------------------------------------------ | ---- |
|
||||
| `export_step` | Export to STEP format (ISO CAD exchange) | All |
|
||||
| `export_stl` | Export to STL format (3D printing) | All |
|
||||
| `export_3mf` | Export to 3MF format (modern 3D printing) | All |
|
||||
| `export_obj` | Export to OBJ format (Wavefront) | All |
|
||||
| `export_iges` | Export to IGES format (older CAD exchange) | All |
|
||||
| `import_step` | Import a STEP file | All |
|
||||
| `import_stl` | Import an STL file as mesh | All |
|
||||
|
||||
#### Macro Management (6 tools)
|
||||
|
||||
| Tool | Description | Mode |
|
||||
| ---------------------------- | ---------------------------------------------- | ---- |
|
||||
| `list_macros` | List all available FreeCAD macros | All |
|
||||
| `run_macro` | Execute a macro by name | All |
|
||||
| `create_macro` | Create a new macro file | All |
|
||||
| `read_macro` | Read macro source code | All |
|
||||
| `delete_macro` | Delete a user macro | All |
|
||||
| `create_macro_from_template` | Create macro from template (basic, part, etc.) | All |
|
||||
|
||||
#### Parts Library (2 tools)
|
||||
|
||||
| Tool | Description | Mode |
|
||||
| -------------------------- | ------------------------------------- | ---- |
|
||||
| `list_parts_library` | List parts in FreeCAD's parts library | All |
|
||||
| `insert_part_from_library` | Insert a part from the library | All |
|
||||
|
||||
---
|
||||
|
||||
## FreeCAD Macros
|
||||
|
||||
This project includes standalone FreeCAD macros that can be used independently of the MCP server. These are useful for FreeCAD users who want the macros without setting up the full MCP integration.
|
||||
|
||||
### CutObjectForMagnets
|
||||
|
||||
Intelligently cuts 3D objects along a plane and automatically places magnet holes with built-in surface penetration detection. Perfect for creating multi-part prints that snap together with magnets.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Smart surface detection - skips holes that would penetrate outer surfaces
|
||||
- Supports preset planes (XY, XZ, YZ) or custom datum planes for angled cuts
|
||||
- Count-based hole placement with even distribution
|
||||
- Dual-part validation ensures alignment
|
||||
- Non-destructive (original object is hidden, not deleted)
|
||||
|
||||
**Installation:**
|
||||
|
||||
```bash
|
||||
# If you have the source:
|
||||
just install-cut-macro
|
||||
|
||||
# Or manually copy CutObjectForMagnets.FCMacro from macros/Cut_Object_for_Magnets/
|
||||
# to your FreeCAD macro directory:
|
||||
# macOS: ~/Library/Application Support/FreeCAD/Macro/
|
||||
# Linux: ~/.local/share/FreeCAD/Macro/
|
||||
# Windows: %APPDATA%/FreeCAD/Macro/
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
1. Open your model in FreeCAD
|
||||
1. Select the object to cut
|
||||
1. Go to **Macro -> Macros... -> CutObjectForMagnets -> Execute**
|
||||
1. Configure the cut plane and magnet hole parameters
|
||||
1. Click **Execute Cut**
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **Plane:** XY, XZ, YZ, or a model datum plane
|
||||
- **Offset:** Distance from origin (for preset planes)
|
||||
- **Hole diameter:** Size of magnet holes (e.g., 6.2mm for 6mm magnets)
|
||||
- **Hole depth:** How deep holes go into each piece
|
||||
- **Number of holes:** Total holes to create (evenly distributed)
|
||||
- **Edge clearance:** Distance from hole edge to outer surface
|
||||
|
||||
See [macros/Cut_Object_for_Magnets/README-CutObjectForMagnets.md](macros/Cut_Object_for_Magnets/README-CutObjectForMagnets.md) for detailed documentation.
|
||||
|
||||
**Uninstall:**
|
||||
|
||||
```bash
|
||||
just uninstall-cut-macro
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## For Developers
|
||||
|
||||
This section covers development setup, contributing, and working with the codebase.
|
||||
|
||||
## MCP Server Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [mise](https://mise.jdx.dev/) - Tool version manager
|
||||
- [FreeCAD](https://www.freecadweb.org/) 0.21+ or 1.0+
|
||||
|
||||
### Initial Setup
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/spkane/freecad-mcp.git
|
||||
cd freecad-mcp
|
||||
|
||||
# Install mise (if not already installed)
|
||||
curl https://mise.run | sh
|
||||
|
||||
# Install all tools (Python 3.11, uv, just, pre-commit)
|
||||
mise trust
|
||||
mise install
|
||||
|
||||
# Set up the development environment
|
||||
just setup
|
||||
```
|
||||
|
||||
This installs:
|
||||
|
||||
- **Python 3.11** - Required for FreeCAD ABI compatibility
|
||||
- **uv** - Fast Python package manager
|
||||
- **just** - Command runner for development workflows
|
||||
- **pre-commit** - Git hooks for code quality
|
||||
|
||||
### MCP Client Configuration (Development)
|
||||
|
||||
Create a `.mcp.json` file in the project directory:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"freecad": {
|
||||
"command": "/path/to/mise/shims/uv",
|
||||
"args": ["run", "--project", "/path/to/freecad-mcp", "freecad-mcp"],
|
||||
"env": {
|
||||
"FREECAD_MODE": "xmlrpc",
|
||||
"FREECAD_SOCKET_HOST": "localhost",
|
||||
"FREECAD_XMLRPC_PORT": "9875",
|
||||
"PATH": "/path/to/mise/shims:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Replace the paths with your actual paths:**
|
||||
|
||||
| Placeholder | Description | Example (macOS) |
|
||||
| ------------------------ | ------------------------------ | ------------------------------ |
|
||||
| `/path/to/mise/shims/uv` | Full path to uv via mise shims | `~/.local/share/mise/shims/uv` |
|
||||
| `/path/to/freecad-mcp` | Project directory | `~/dev/freecad-mcp` |
|
||||
| `/path/to/mise/shims` | mise shims directory for PATH | `~/.local/share/mise/shims` |
|
||||
|
||||
**Finding your mise shims path:**
|
||||
|
||||
```bash
|
||||
mise where uv | sed 's|/installs/.*|/shims|'
|
||||
# Or simply:
|
||||
echo ~/.local/share/mise/shims
|
||||
```
|
||||
|
||||
### Development Workflow
|
||||
|
||||
Commands are organized into modules. Use `just --list` to see all shortcuts, or `just --list <module>` to see module-specific commands.
|
||||
|
||||
```bash
|
||||
# Show all available commands
|
||||
just --list
|
||||
|
||||
# Show commands in a specific module
|
||||
just --list docker
|
||||
just --list quality
|
||||
just --list testing
|
||||
just --list freecad
|
||||
|
||||
# Install/update dependencies
|
||||
just install
|
||||
|
||||
# Run all checks (linting, type checking, tests)
|
||||
just all
|
||||
|
||||
# Individual checks (shortcuts to module commands)
|
||||
just lint # Run ruff linter (quality::lint)
|
||||
just typecheck # Run mypy type checker (quality::typecheck)
|
||||
just test # Run unit tests (testing::unit)
|
||||
just check # Run all pre-commit hooks (quality::check)
|
||||
|
||||
# Format code
|
||||
just format
|
||||
|
||||
# Run with debug logging
|
||||
just run-debug
|
||||
|
||||
# Docker commands
|
||||
just docker::build # Build image for local architecture
|
||||
just docker::build-multi # Build multi-arch image (amd64 + arm64)
|
||||
just docker::run # Run container
|
||||
```
|
||||
|
||||
### Running FreeCAD with the MCP Bridge
|
||||
|
||||
#### 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)
|
||||
|
||||
```bash
|
||||
just run-headless
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Unit tests only (no FreeCAD required)
|
||||
just test
|
||||
|
||||
# Unit tests with coverage
|
||||
just test-cov
|
||||
|
||||
# Integration tests (requires running FreeCAD bridge)
|
||||
just test-integration
|
||||
|
||||
# All tests with automatic FreeCAD startup
|
||||
just integration
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
The project uses strict code quality checks via pre-commit:
|
||||
|
||||
- **Ruff** - Linting and formatting
|
||||
- **MyPy** - Type checking
|
||||
- **Bandit** - Security scanning
|
||||
- **Codespell** - Spell checking
|
||||
- **Secrets scanning** - Gitleaks, detect-secrets, TruffleHog
|
||||
|
||||
```bash
|
||||
# Run all pre-commit hooks
|
||||
just check
|
||||
|
||||
# Run security/secrets scans
|
||||
just security
|
||||
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/`
|
||||
|
||||
**Installation for development:**
|
||||
|
||||
```bash
|
||||
just install-cut-macro
|
||||
```
|
||||
|
||||
**Uninstall:**
|
||||
|
||||
```bash
|
||||
just uninstall-cut-macro
|
||||
```
|
||||
|
||||
See [macros/Cut_Object_for_Magnets/README-CutObjectForMagnets.md](macros/Cut_Object_for_Magnets/README-CutObjectForMagnets.md) for detailed documentation on the macro's internals.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
See [ARCHITECTURE-MCP.md](ARCHITECTURE-MCP.md) for detailed design documentation covering:
|
||||
|
||||
- Module structure
|
||||
- Bridge communication protocols
|
||||
- Tool registration patterns
|
||||
- FreeCAD plugin architecture
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
@@ -0,0 +1,307 @@
|
||||
# FreeCAD MCP Server Comparison Analysis
|
||||
|
||||
This document analyzes existing FreeCAD MCP server implementations to identify best practices and improvements for our architecture.
|
||||
|
||||
## Existing Implementations
|
||||
|
||||
### 1. [neka-nat/freecad-mcp](https://github.com/neka-nat/freecad-mcp) (380+ stars)
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- XML-RPC server running inside FreeCAD (port 9875)
|
||||
- Queue-based GUI communication for thread safety
|
||||
- FastMCP-based MCP server connecting via XML-RPC
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- `create_object()` supports Part::, Draft::, PartDesign::, Fem:: types
|
||||
- `get_view()` captures screenshots with multiple perspectives
|
||||
- `insert_part_from_library()` for part library access
|
||||
- Smart screenshot handling (detects unsupported views like TechDraw)
|
||||
|
||||
**Strengths:**
|
||||
|
||||
- Thread-safe queue system for GUI operations
|
||||
- Comprehensive object type support
|
||||
- Screenshot capabilities with view selection
|
||||
- Parts library integration
|
||||
|
||||
**Weaknesses:**
|
||||
|
||||
- XML-RPC only (no embedded/headless mode)
|
||||
- No macro development tools
|
||||
- Limited debugging capabilities
|
||||
|
||||
---
|
||||
|
||||
### 2. [jango-blockchained/mcp-freecad](https://github.com/jango-blockchained/mcp-freecad)
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- 6 connection modes: Launcher, Server, Bridge, RPC, Wrapper, Mock
|
||||
- FastMCP 2.13.0+ with FastAPI integration
|
||||
- Multi-provider AI support (Claude, OpenAI, Google, OpenRouter)
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- AppImage/AppRun launcher integration
|
||||
- Connection recovery mechanisms
|
||||
- Resource caching and performance diagnostics
|
||||
- Modern GUI addon with real-time diagnostics
|
||||
|
||||
**Strengths:**
|
||||
|
||||
- Most flexible connection options
|
||||
- Multi-AI provider support
|
||||
- Performance monitoring
|
||||
- Connection recovery/resilience
|
||||
|
||||
**Weaknesses:**
|
||||
|
||||
- Complex setup with many moving parts
|
||||
- Heavier dependency footprint
|
||||
|
||||
---
|
||||
|
||||
### 3. [contextform/freecad-mcp](https://github.com/contextform/freecad-mcp)
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- Node.js-based MCP bridge (`working_bridge.py`)
|
||||
- AICopilot workbench integrated into FreeCAD
|
||||
- Cross-platform installer (`freecad-mcp-setup`)
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- 13 PartDesign operations (Pad, Revolution, Fillet, Chamfer, etc.)
|
||||
- 18 Part operations (Primitives, Booleans, Transforms)
|
||||
- 14 View control tools
|
||||
- Automated installer with OS detection
|
||||
|
||||
**Strengths:**
|
||||
|
||||
- Most comprehensive CAD operation coverage
|
||||
- Excellent PartDesign/Part workbench integration
|
||||
- Easy installation process
|
||||
- Demo showcasing full workflow (house modeling)
|
||||
|
||||
**Weaknesses:**
|
||||
|
||||
- Node.js dependency adds complexity
|
||||
- Less focus on debugging/development workflows
|
||||
|
||||
---
|
||||
|
||||
### 4. [ATOI-Ming/FreeCAD-MCP](https://github.com/ATOI-Ming/FreeCAD-MCP)
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- Server-client with stdio/TCP (port 9876)
|
||||
- GUI control panel inside FreeCAD
|
||||
- Macro-centric workflow
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Macro templates (default, basic, part, sketch)
|
||||
- Automatic import injection in macros
|
||||
- Macro validation before execution
|
||||
- View control (front, top, right, axonometric)
|
||||
- Log management and report browser
|
||||
|
||||
**Strengths:**
|
||||
|
||||
- Focus on macro development workflow
|
||||
- GUI panel for easy control
|
||||
- Automatic boilerplate injection
|
||||
- Comprehensive logging
|
||||
|
||||
**Weaknesses:**
|
||||
|
||||
- Macro-only focus (no direct object creation)
|
||||
- Windows-centric paths in documentation
|
||||
|
||||
---
|
||||
|
||||
### 5. [bonninr/freecad_mcp](https://github.com/bonninr/freecad_mcp)
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- Simple socket-based server
|
||||
- Two primary tools: `get_scene_info` and `run_script`
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Comprehensive scene information retrieval
|
||||
- Arbitrary Python code execution
|
||||
- Minimal, focused API
|
||||
|
||||
**Strengths:**
|
||||
|
||||
- Simple, easy to understand
|
||||
- Full Python access via `run_script`
|
||||
- Lightweight
|
||||
|
||||
**Weaknesses:**
|
||||
|
||||
- Very minimal tool set
|
||||
- No specialized CAD operations
|
||||
|
||||
---
|
||||
|
||||
## Feature Comparison Matrix
|
||||
|
||||
| Feature | neka-nat | jango | contextform | ATOI-Ming | bonninr | **Ours** |
|
||||
| ------------------- | -------- | ----- | ----------- | --------- | ------- | -------- |
|
||||
| Headless mode | No | Yes | No | No | No | **Yes** |
|
||||
| GUI mode | Yes | Yes | Yes | Yes | Yes | **Yes** |
|
||||
| XML-RPC | Yes | Yes | No | No | No | **Yes** |
|
||||
| Socket/TCP | No | Yes | No | Yes | Yes | **Yes** |
|
||||
| Embedded Python | No | No | No | No | No | **Yes** |
|
||||
| Screenshot capture | Yes | Yes | Yes | No | No | **Yes** |
|
||||
| Parts library | Yes | No | No | No | No | **Yes** |
|
||||
| Macro support | No | No | No | Yes | No | **Yes** |
|
||||
| PartDesign tools | Basic | Yes | Yes | Via macro | No | **Yes** |
|
||||
| Boolean operations | Yes | Yes | Yes | Via macro | No | **Yes** |
|
||||
| FEM support | Yes | No | No | No | No | **Yes** |
|
||||
| Multi-AI provider | No | Yes | No | No | No | No |
|
||||
| Connection recovery | No | Yes | No | No | No | **Yes** |
|
||||
| Thread-safe GUI ops | Yes | Yes | Unknown | Unknown | Unknown | **Yes** |
|
||||
| MCP Resources | No | No | No | No | No | **Yes** |
|
||||
| MCP Prompts | No | No | No | No | No | **Yes** |
|
||||
|
||||
---
|
||||
|
||||
## Key Learnings & Improvements for Our Architecture
|
||||
|
||||
### 1. Communication Protocol
|
||||
|
||||
**Learning:** neka-nat uses XML-RPC which is proven and reliable.
|
||||
|
||||
**Improvement:** Support both XML-RPC (for compatibility) and JSON-RPC over sockets (simpler, more modern). Add embedded mode for true headless operation.
|
||||
|
||||
### 2. Thread-Safe GUI Operations
|
||||
|
||||
**Learning:** neka-nat's queue-based system is essential for GUI stability.
|
||||
|
||||
**Improvement:** Implement similar queue system with Qt timer for main thread execution:
|
||||
|
||||
```python
|
||||
# Queue-based GUI communication
|
||||
rpc_request_queue = queue.Queue()
|
||||
rpc_response_queue = queue.Queue()
|
||||
|
||||
def process_gui_tasks():
|
||||
"""Execute queued operations on main GUI thread."""
|
||||
while not rpc_request_queue.empty():
|
||||
task = rpc_request_queue.get()
|
||||
result = task()
|
||||
rpc_response_queue.put(result)
|
||||
```
|
||||
|
||||
### 3. Screenshot Capabilities
|
||||
|
||||
**Learning:** neka-nat handles unsupported view types gracefully.
|
||||
|
||||
**Improvement:** Add comprehensive view support with intelligent fallbacks:
|
||||
|
||||
- Multiple view angles (Isometric, Front, Top, Right, etc.)
|
||||
- View type detection (skip TechDraw, Spreadsheet)
|
||||
- Configurable resolution and format
|
||||
|
||||
### 4. Macro Development Focus
|
||||
|
||||
**Learning:** ATOI-Ming's macro-centric approach is unique and valuable.
|
||||
|
||||
**Improvement:** Add dedicated macro tools:
|
||||
|
||||
- `create_macro` with templates
|
||||
- `validate_macro` for syntax checking
|
||||
- `run_macro` with parameter passing
|
||||
- Automatic import injection
|
||||
|
||||
### 5. PartDesign Integration
|
||||
|
||||
**Learning:** contextform has the most comprehensive PartDesign coverage.
|
||||
|
||||
**Improvement:** Add specialized PartDesign tools:
|
||||
|
||||
- Pad, Pocket, Revolution, Groove
|
||||
- Fillet, Chamfer
|
||||
- Hole (with standards support)
|
||||
- LinearPattern, PolarPattern
|
||||
- Mirrored
|
||||
|
||||
### 6. Connection Resilience
|
||||
|
||||
**Learning:** jango-blockchained has connection recovery mechanisms.
|
||||
|
||||
**Improvement:** Add connection health monitoring and auto-reconnect:
|
||||
|
||||
```python
|
||||
async def maintain_connection(self):
|
||||
"""Background task to maintain connection health."""
|
||||
while self._running:
|
||||
if not await self.is_connected():
|
||||
await self.reconnect()
|
||||
await asyncio.sleep(5)
|
||||
```
|
||||
|
||||
### 7. Parts Library Integration
|
||||
|
||||
**Learning:** neka-nat provides access to FreeCAD parts library.
|
||||
|
||||
**Improvement:** Expose parts library with search and filtering.
|
||||
|
||||
### 8. Comprehensive Logging
|
||||
|
||||
**Learning:** ATOI-Ming has excellent logging with GUI browser.
|
||||
|
||||
**Improvement:** Add structured logging with levels, rotation, and optional GUI viewer in plugin.
|
||||
|
||||
---
|
||||
|
||||
## Updated Architecture Decisions
|
||||
|
||||
Based on this analysis, our architecture will:
|
||||
|
||||
1. **Support 3 connection modes:**
|
||||
|
||||
- Embedded (headless, in-process FreeCAD)
|
||||
- XML-RPC (proven, compatible with neka-nat addon)
|
||||
- JSON-RPC over socket (modern, simpler)
|
||||
|
||||
1. **Implement queue-based GUI thread safety** following neka-nat's pattern
|
||||
|
||||
1. **Provide comprehensive tool coverage:**
|
||||
|
||||
- Execution: Python, macros
|
||||
- Documents: create, open, save, list
|
||||
- Objects: create, edit, delete, query
|
||||
- PartDesign: pad, pocket, fillet, chamfer, patterns
|
||||
- Part: primitives, booleans, transforms
|
||||
- Export: STEP, STL, OBJ, FreeCAD native
|
||||
- View: screenshot, camera control
|
||||
|
||||
1. **Add unique features:**
|
||||
|
||||
- MCP Resources for document introspection
|
||||
- MCP Prompts for guided workflows
|
||||
- Macro development toolkit
|
||||
- Workbench-specific tools
|
||||
|
||||
1. **Focus on developer experience:**
|
||||
|
||||
- Debugging tools
|
||||
- Error introspection
|
||||
- Console history access
|
||||
- Constraint validation
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [neka-nat/freecad-mcp](https://github.com/neka-nat/freecad-mcp)
|
||||
- [jango-blockchained/mcp-freecad](https://github.com/jango-blockchained/mcp-freecad)
|
||||
- [contextform/freecad-mcp](https://github.com/contextform/freecad-mcp)
|
||||
- [ATOI-Ming/FreeCAD-MCP](https://github.com/ATOI-Ming/FreeCAD-MCP)
|
||||
- [bonninr/freecad_mcp](https://github.com/bonninr/freecad_mcp)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,485 @@
|
||||
# FreeCAD MCP User Guide
|
||||
|
||||
This guide explains how to use AI assistants with FreeCAD via the MCP (Model Context Protocol) server to create and manipulate 3D CAD models.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Getting Started](#getting-started)
|
||||
1. [Running Modes](#running-modes)
|
||||
1. [Basic Workflows](#basic-workflows)
|
||||
1. [Object Creation Examples](#object-creation-examples)
|
||||
1. [PartDesign Workflow](#partdesign-workflow)
|
||||
1. [Complete Example: Mounting Bracket](#complete-example-mounting-bracket)
|
||||
1. [Tips and Best Practices](#tips-and-best-practices)
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **FreeCAD installed** - Version 1.0.x or later
|
||||
1. **An MCP client** (Claude Code, or other MCP-compatible AI assistant) configured
|
||||
1. **Python 3.11** - Must match FreeCAD's bundled Python version
|
||||
|
||||
### Starting FreeCAD with MCP Bridge
|
||||
|
||||
You have two options depending on your workflow:
|
||||
|
||||
#### Headless Mode (Command-line only)
|
||||
|
||||
Best for automated workflows, batch processing, or when you don't need visual feedback.
|
||||
|
||||
```bash
|
||||
just run-headless
|
||||
```
|
||||
|
||||
**Capabilities:** All modeling operations, export, scripting.
|
||||
**Limitations:** No screenshots, no visual feedback, no color/visibility control.
|
||||
|
||||
#### GUI Mode (Full graphical interface)
|
||||
|
||||
Best for interactive design work where you want to see results visually.
|
||||
|
||||
```bash
|
||||
just run-gui
|
||||
```
|
||||
|
||||
**Capabilities:** Everything headless mode can do, plus screenshots, colors, view control.
|
||||
|
||||
### Verifying Connection
|
||||
|
||||
Once FreeCAD is running with the MCP bridge, you can verify the connection:
|
||||
|
||||
```text
|
||||
"Check the FreeCAD connection status"
|
||||
```
|
||||
|
||||
Claude will use the `get_connection_status` tool to confirm the bridge is working.
|
||||
|
||||
---
|
||||
|
||||
## Running Modes
|
||||
|
||||
### Headless vs GUI Mode
|
||||
|
||||
| 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 |
|
||||
|
||||
### Detecting the Current Mode
|
||||
|
||||
When working with Claude, it will automatically detect whether FreeCAD is in GUI or headless mode and adapt accordingly. GUI-only operations will return informative errors in headless mode rather than crashing.
|
||||
|
||||
---
|
||||
|
||||
## Basic Workflows
|
||||
|
||||
### Creating a Document
|
||||
|
||||
Every FreeCAD project needs a document. You can ask Claude:
|
||||
|
||||
```text
|
||||
"Create a new FreeCAD document called 'MyProject'"
|
||||
```
|
||||
|
||||
Claude will use the `create_document` tool.
|
||||
|
||||
### Creating Simple Shapes
|
||||
|
||||
Ask Claude to create primitive shapes:
|
||||
|
||||
```text
|
||||
"Create a box that's 50mm long, 30mm wide, and 10mm tall"
|
||||
"Add a cylinder with radius 5mm and height 20mm"
|
||||
"Create a sphere with 15mm radius"
|
||||
```
|
||||
|
||||
### Positioning Objects
|
||||
|
||||
Move and rotate objects:
|
||||
|
||||
```text
|
||||
"Move the box to position (100, 50, 0)"
|
||||
"Rotate the cylinder 45 degrees around the Z axis"
|
||||
```
|
||||
|
||||
### Boolean Operations
|
||||
|
||||
Combine shapes:
|
||||
|
||||
```text
|
||||
"Fuse the box and cylinder together"
|
||||
"Cut a hole through the box using the cylinder"
|
||||
"Find the intersection of the two shapes"
|
||||
```
|
||||
|
||||
### Saving and Exporting
|
||||
|
||||
```text
|
||||
"Save the document as MyProject.FCStd"
|
||||
"Export the model to STEP format"
|
||||
"Export for 3D printing as STL"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Object Creation Examples
|
||||
|
||||
### Example 1: Simple Box with Hole
|
||||
|
||||
**Request:**
|
||||
|
||||
```text
|
||||
Create a 50x50x20mm box with a 10mm diameter hole through the center.
|
||||
```
|
||||
|
||||
**What Claude does:**
|
||||
|
||||
1. Creates a document
|
||||
1. Creates a box (50x50x20)
|
||||
1. Creates a cylinder (radius 5, height 30) positioned at center
|
||||
1. Performs boolean cut operation
|
||||
1. Returns the result
|
||||
|
||||
### Example 2: Pipe/Tube Shape
|
||||
|
||||
**Request:**
|
||||
|
||||
```text
|
||||
Create a pipe with outer diameter 40mm, inner diameter 30mm, and length 100mm.
|
||||
```
|
||||
|
||||
**What Claude does:**
|
||||
|
||||
1. Creates outer cylinder (radius 20, height 100)
|
||||
1. Creates inner cylinder (radius 15, height 100)
|
||||
1. Cuts inner from outer to create hollow tube
|
||||
|
||||
### Example 3: L-Bracket
|
||||
|
||||
**Request:**
|
||||
|
||||
```text
|
||||
Create an L-shaped bracket:
|
||||
- Horizontal part: 100mm x 50mm x 5mm
|
||||
- Vertical part: 5mm x 50mm x 80mm standing on the horizontal part
|
||||
```
|
||||
|
||||
**What Claude does:**
|
||||
|
||||
1. Creates horizontal box
|
||||
1. Creates vertical box positioned at correct location
|
||||
1. Fuses them together
|
||||
|
||||
---
|
||||
|
||||
## PartDesign Workflow
|
||||
|
||||
For parametric modeling that maintains design history, use the PartDesign workflow.
|
||||
|
||||
### Understanding PartDesign Concepts
|
||||
|
||||
**Body**: A container for a single solid model built from features.
|
||||
|
||||
**Sketch**: A 2D drawing that defines profiles for 3D operations.
|
||||
|
||||
**Features**: Operations like Pad (extrude), Pocket (cut), Fillet, etc.
|
||||
|
||||
### Basic PartDesign Example
|
||||
|
||||
**Request:**
|
||||
|
||||
```text
|
||||
Create a parametric mounting plate:
|
||||
1. Start with a PartDesign body
|
||||
2. Create a sketch on the XY plane with a 100x60mm rectangle
|
||||
3. Pad it 8mm thick
|
||||
4. Add four 5mm holes near each corner for mounting screws
|
||||
5. Fillet all edges with 2mm radius
|
||||
```
|
||||
|
||||
**What Claude does:**
|
||||
|
||||
1. `create_partdesign_body()` - Creates the Body container
|
||||
1. `create_sketch(body_name="Body", plane="XY_Plane")` - Creates attached sketch
|
||||
1. `add_sketch_rectangle(...)` - Adds the profile geometry
|
||||
1. `pad_sketch(sketch_name="Sketch", length=8)` - Extrudes to solid
|
||||
1. Creates new sketches with points for hole locations
|
||||
1. `create_hole(...)` for each mounting hole
|
||||
1. `fillet_edges(...)` - Rounds the edges
|
||||
|
||||
### Revolving Profiles
|
||||
|
||||
**Request:**
|
||||
|
||||
```text
|
||||
Create a turned part:
|
||||
- Draw a profile on the XZ plane
|
||||
- Revolve it 360 degrees around the X axis
|
||||
```
|
||||
|
||||
**What Claude does:**
|
||||
|
||||
1. Creates body and sketch on XZ plane
|
||||
1. Draws the profile using lines and arcs
|
||||
1. Uses `revolution_sketch()` to create the solid
|
||||
|
||||
### Pattern Operations
|
||||
|
||||
**Request:**
|
||||
|
||||
```text
|
||||
Create a plate with a row of 6 holes spaced 15mm apart.
|
||||
```
|
||||
|
||||
**What Claude does:**
|
||||
|
||||
1. Creates the base plate
|
||||
1. Creates one hole feature
|
||||
1. Uses `linear_pattern()` to repeat the hole
|
||||
|
||||
---
|
||||
|
||||
## Complete Example: Mounting Bracket
|
||||
|
||||
This detailed example shows a complete workflow for creating a practical part.
|
||||
|
||||
### Design Requirements
|
||||
|
||||
Create a mounting bracket with:
|
||||
|
||||
- Base plate: 80mm x 60mm x 5mm
|
||||
- Vertical support: 5mm thick, 50mm tall, 60mm wide
|
||||
- Two mounting holes (6mm diameter) on the base
|
||||
- One slot (10mm x 20mm) on the vertical support
|
||||
- 3mm fillets on external corners
|
||||
|
||||
### Step-by-Step Workflow
|
||||
|
||||
#### Step 1: Ask Claude to create the bracket
|
||||
|
||||
```text
|
||||
Create a mounting bracket with the following specifications:
|
||||
|
||||
Base plate:
|
||||
- Size: 80mm x 60mm x 5mm
|
||||
- Two 6mm diameter mounting holes, centered 15mm from each short edge
|
||||
|
||||
Vertical support:
|
||||
- Attached to one end of the base
|
||||
- 5mm thick, 60mm wide, 50mm tall
|
||||
- One slot: 10mm wide x 20mm tall, centered
|
||||
|
||||
Finish:
|
||||
- 3mm fillet on all outer edges
|
||||
- Export as STEP file when done
|
||||
```
|
||||
|
||||
#### Step 2: Claude's approach
|
||||
|
||||
Claude will break this down into manageable operations:
|
||||
|
||||
```python
|
||||
# 1. Create document and PartDesign body
|
||||
create_document(name="MountingBracket")
|
||||
create_partdesign_body(name="Body")
|
||||
|
||||
# 2. Create base plate sketch and pad
|
||||
create_sketch(body_name="Body", plane="XY_Plane", name="BaseSketch")
|
||||
add_sketch_rectangle(sketch_name="BaseSketch", x=0, y=0, width=80, height=60)
|
||||
pad_sketch(sketch_name="BaseSketch", length=5)
|
||||
|
||||
# 3. Add vertical support
|
||||
create_sketch(body_name="Body", plane="XZ_Plane", name="SupportSketch")
|
||||
# ... add geometry
|
||||
pad_sketch(sketch_name="SupportSketch", length=60)
|
||||
|
||||
# 4. Add mounting holes
|
||||
create_sketch(body_name="Body", plane="XY_Plane", name="HoleSketch")
|
||||
add_sketch_point(sketch_name="HoleSketch", x=15, y=30)
|
||||
add_sketch_point(sketch_name="HoleSketch", x=65, y=30)
|
||||
create_hole(sketch_name="HoleSketch", diameter=6, hole_type="ThroughAll")
|
||||
|
||||
# 5. Add slot (as pocket)
|
||||
create_sketch(body_name="Body", plane="Face...", name="SlotSketch")
|
||||
add_sketch_rectangle(...)
|
||||
pocket_sketch(sketch_name="SlotSketch", length=5, type="ThroughAll")
|
||||
|
||||
# 6. Add fillets
|
||||
fillet_edges(object_name="...", radius=3)
|
||||
|
||||
# 7. Export
|
||||
export_step(file_path="/path/to/bracket.step")
|
||||
```
|
||||
|
||||
#### Step 3: View the result (GUI mode)
|
||||
|
||||
```text
|
||||
Take a screenshot of the bracket from an isometric view
|
||||
```
|
||||
|
||||
Claude will use `get_screenshot(view_angle="Isometric")` to capture and display the result.
|
||||
|
||||
---
|
||||
|
||||
## Tips and Best Practices
|
||||
|
||||
### 1. Be Specific with Dimensions
|
||||
|
||||
**Good:** "Create a box 50mm x 30mm x 10mm"
|
||||
|
||||
**Vague:** "Create a small box"
|
||||
|
||||
### 2. Specify Units
|
||||
|
||||
FreeCAD uses millimeters by default. Always include units to avoid confusion:
|
||||
|
||||
```text
|
||||
"Create a cylinder with 25.4mm (1 inch) diameter"
|
||||
```
|
||||
|
||||
### 3. Use Meaningful Names
|
||||
|
||||
```text
|
||||
"Create a box named 'BasePlate' and a cylinder named 'MountingHole'"
|
||||
```
|
||||
|
||||
This makes it easier to reference objects later.
|
||||
|
||||
### 4. Work Incrementally
|
||||
|
||||
For complex parts, build step by step:
|
||||
|
||||
1. Create the basic shape
|
||||
1. Verify it looks correct
|
||||
1. Add features one at a time
|
||||
1. Check after each major operation
|
||||
|
||||
### 5. Save Frequently
|
||||
|
||||
```text
|
||||
"Save the document"
|
||||
```
|
||||
|
||||
FreeCAD can crash, and you don't want to lose work.
|
||||
|
||||
### 6. Use PartDesign for Parametric Parts
|
||||
|
||||
If you might need to modify dimensions later, use the PartDesign workflow with sketches rather than direct Part operations.
|
||||
|
||||
### 7. Export to Multiple Formats
|
||||
|
||||
For manufacturing or 3D printing:
|
||||
|
||||
```text
|
||||
"Export as STEP for CNC machining and STL for 3D printing"
|
||||
```
|
||||
|
||||
### 8. Use the execute_python Tool for Advanced Operations
|
||||
|
||||
For operations not covered by the standard tools, Claude can execute custom Python:
|
||||
|
||||
```text
|
||||
"Calculate the volume and center of mass of the part"
|
||||
```
|
||||
|
||||
Claude will use `execute_python()` to run the necessary FreeCAD Python commands.
|
||||
|
||||
### 9. Check GUI Mode for Visual Features
|
||||
|
||||
Before asking for screenshots or colors:
|
||||
|
||||
```text
|
||||
"Is FreeCAD running in GUI mode?"
|
||||
```
|
||||
|
||||
### 10. Use Patterns for Repetitive Features
|
||||
|
||||
Instead of creating many individual features:
|
||||
|
||||
```text
|
||||
"Create one hole and pattern it in a 4x3 grid with 20mm spacing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Operations Quick Reference
|
||||
|
||||
| Task | How to Ask |
|
||||
| ---------------- | ------------------------------------------------------- |
|
||||
| Create box | "Create a box 50x30x10mm" |
|
||||
| Create cylinder | "Create a cylinder with 10mm radius and 20mm height" |
|
||||
| Move object | "Move MyBox to position (100, 50, 0)" |
|
||||
| Rotate object | "Rotate MyCylinder 45 degrees around Z axis" |
|
||||
| Boolean union | "Fuse Box and Cylinder together" |
|
||||
| Boolean subtract | "Cut Cylinder from Box" |
|
||||
| Create hole | "Add a 6mm through hole at (25, 15)" |
|
||||
| Fillet edges | "Add 3mm fillet to all edges" |
|
||||
| Chamfer edges | "Add 2mm chamfer to selected edges" |
|
||||
| Export STEP | "Export to STEP format" |
|
||||
| Export STL | "Export for 3D printing" |
|
||||
| Save document | "Save the document as MyPart.FCStd" |
|
||||
| Screenshot | "Take a screenshot from the front view" (GUI mode only) |
|
||||
| Change color | "Make the box red" (GUI mode only) |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "GUI not available" Error
|
||||
|
||||
You're running in headless mode and trying to use a GUI-only feature. Either:
|
||||
|
||||
- Switch to GUI mode: `just run-gui`
|
||||
- Use an alternative approach (e.g., skip visual operations)
|
||||
|
||||
### Objects Not Appearing
|
||||
|
||||
Make sure to recompute the document:
|
||||
|
||||
```text
|
||||
"Recompute the document"
|
||||
```
|
||||
|
||||
### Boolean Operations Failing
|
||||
|
||||
Ensure:
|
||||
|
||||
1. Both objects have valid shapes
|
||||
1. The objects actually intersect
|
||||
1. Objects are in the same document
|
||||
|
||||
### Sketch Errors
|
||||
|
||||
Sketches need to be fully constrained for PartDesign operations. Ask Claude to:
|
||||
|
||||
```text
|
||||
"Check if the sketch is fully constrained"
|
||||
```
|
||||
|
||||
### Connection Issues
|
||||
|
||||
If the MCP bridge isn't responding:
|
||||
|
||||
1. Check FreeCAD is running with the bridge started
|
||||
1. Verify ports 9875 (XML-RPC) and 9876 (socket) are available
|
||||
1. Restart FreeCAD with `just run-gui` or `just run-headless`
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- See [MCP_TOOLS_REFERENCE.md](MCP_TOOLS_REFERENCE.md) for detailed API documentation
|
||||
- Explore the FreeCAD wiki for advanced techniques
|
||||
- Practice with simple parts before attempting complex assemblies
|
||||
@@ -0,0 +1,58 @@
|
||||
# FreeCAD MCP Server
|
||||
|
||||
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 |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Clone and setup
|
||||
git clone https://github.com/spkane/freecad-mcp.git
|
||||
cd freecad-mcp
|
||||
mise install
|
||||
just setup
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
See the [README](../README.md) for detailed installation and configuration 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
|
||||
|
||||
| Mode | Description | Platform |
|
||||
| ---------- | ---------------------------- | --------------------------- |
|
||||
| `xmlrpc` | XML-RPC protocol (port 9875) | All platforms (recommended) |
|
||||
| `socket` | JSON-RPC socket (port 9876) | All platforms |
|
||||
| `embedded` | In-process FreeCAD | Linux only |
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,244 @@
|
||||
# Docker build and run commands
|
||||
# Usage: just docker::build, just docker::build-all, etc.
|
||||
|
||||
# Default Docker image name
|
||||
image_name := "freecad-mcp"
|
||||
registry := "spkane"
|
||||
|
||||
# Project root directory (justfile_directory() returns the main justfile's directory)
|
||||
project_root := justfile_directory()
|
||||
|
||||
# Build Docker image for local architecture only
|
||||
build:
|
||||
docker build -t {{image_name}} {{project_root}}
|
||||
|
||||
# Build Docker image with specific tag
|
||||
build-tag tag:
|
||||
docker build -t {{image_name}}:{{tag}} {{project_root}}
|
||||
|
||||
# Build multi-architecture image (amd64 and arm64)
|
||||
build-multi:
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t {{image_name}} {{project_root}}
|
||||
|
||||
# Build and push multi-architecture image to registry
|
||||
build-push tag="latest":
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t {{registry}}/{{image_name}}:{{tag}} \
|
||||
--push {{project_root}}
|
||||
|
||||
# Build and load multi-architecture image locally (loads current arch only)
|
||||
build-load:
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t {{image_name}} \
|
||||
--load {{project_root}}
|
||||
|
||||
# Run the Docker container (connects to FreeCAD on host)
|
||||
run:
|
||||
docker run --rm -i \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
-e FREECAD_MODE=xmlrpc \
|
||||
-e FREECAD_SOCKET_HOST=host.docker.internal \
|
||||
{{image_name}}
|
||||
|
||||
# Run with custom environment variables
|
||||
run-env *args:
|
||||
docker run --rm -i \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
{{args}} \
|
||||
{{image_name}}
|
||||
|
||||
# Run container interactively with shell
|
||||
shell:
|
||||
docker run --rm -it \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
-e FREECAD_MODE=xmlrpc \
|
||||
-e FREECAD_SOCKET_HOST=host.docker.internal \
|
||||
--entrypoint /bin/bash \
|
||||
{{image_name}}
|
||||
|
||||
# Show image size and layers
|
||||
inspect:
|
||||
docker images {{image_name}}
|
||||
@echo ""
|
||||
docker history {{image_name}}
|
||||
|
||||
# Remove local Docker image
|
||||
clean:
|
||||
docker rmi {{image_name}} 2>/dev/null || true
|
||||
docker rmi {{registry}}/{{image_name}} 2>/dev/null || true
|
||||
|
||||
# Create and configure buildx builder for multi-arch builds
|
||||
setup-buildx:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if ! docker buildx inspect freecad-builder > /dev/null 2>&1; then
|
||||
echo "Creating buildx builder 'freecad-builder'..."
|
||||
docker buildx create --name freecad-builder --use --bootstrap
|
||||
else
|
||||
echo "Builder 'freecad-builder' already exists, using it..."
|
||||
docker buildx use freecad-builder
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
# Test Docker container integration with FreeCAD
|
||||
# This builds the image, starts FreeCAD with the bridge, runs the container,
|
||||
# and verifies communication is working correctly.
|
||||
test:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "=========================================="
|
||||
echo "Docker Integration Test"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Function to check if XML-RPC server is ready using a simple POST request
|
||||
check_xmlrpc() {
|
||||
# Send a minimal XML-RPC system.listMethods call
|
||||
curl -s --max-time 2 -X POST \
|
||||
-H "Content-Type: text/xml" \
|
||||
-d '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>' \
|
||||
http://localhost:9875 > /dev/null 2>&1
|
||||
}
|
||||
|
||||
# Build the Docker image
|
||||
echo "Step 1: Building Docker image..."
|
||||
docker build -t {{image_name}}:test {{project_root}}
|
||||
echo "✓ Docker image built successfully"
|
||||
echo ""
|
||||
|
||||
# Check if FreeCAD bridge is already running
|
||||
echo "Step 2: Checking for FreeCAD MCP bridge..."
|
||||
if check_xmlrpc; then
|
||||
echo "✓ FreeCAD MCP bridge is already running on port 9875"
|
||||
STARTED_FREECAD=false
|
||||
else
|
||||
echo "FreeCAD MCP bridge not detected. Starting FreeCAD headless..."
|
||||
echo " (This may take 30-60 seconds for FreeCAD to initialize...)"
|
||||
|
||||
# Start FreeCAD headless in background, capturing output
|
||||
FREECAD_LOG=$(mktemp)
|
||||
just freecad::run-headless > "$FREECAD_LOG" 2>&1 &
|
||||
FREECAD_PID=$!
|
||||
|
||||
# Wait for bridge to be ready (longer timeout for FreeCAD startup)
|
||||
MAX_RETRIES=60
|
||||
RETRY_COUNT=0
|
||||
while ! check_xmlrpc; do
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
|
||||
echo "✗ ERROR: FreeCAD MCP bridge did not start within ${MAX_RETRIES}s"
|
||||
echo ""
|
||||
echo "FreeCAD log output:"
|
||||
cat "$FREECAD_LOG" | tail -30
|
||||
rm -f "$FREECAD_LOG"
|
||||
kill $FREECAD_PID 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
# Show progress less frequently to reduce noise
|
||||
if [ $((RETRY_COUNT % 5)) -eq 0 ]; then
|
||||
echo " Waiting for bridge... ($RETRY_COUNT/$MAX_RETRIES)"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
rm -f "$FREECAD_LOG"
|
||||
echo "✓ FreeCAD MCP bridge started (took ${RETRY_COUNT}s)"
|
||||
STARTED_FREECAD=true
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Run the container and test communication
|
||||
echo "Step 3: Running container and testing MCP communication..."
|
||||
echo " Running MCP server in container..."
|
||||
|
||||
# Send MCP initialize and tool call requests via JSON-RPC over stdio
|
||||
# Note: Using printf with \n to avoid just parsing issues with unindented lines
|
||||
MCP_INIT='{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
|
||||
MCP_CALL='{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_mcp_server_environment","arguments":{}}}'
|
||||
|
||||
CONTAINER_OUTPUT=$(printf '%s\n%s\n' "$MCP_INIT" "$MCP_CALL" | \
|
||||
timeout 30 docker run --rm -i \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
-e FREECAD_MODE=xmlrpc \
|
||||
-e FREECAD_SOCKET_HOST=host.docker.internal \
|
||||
{{image_name}}:test 2>&1) || {
|
||||
echo "✗ Container failed to respond"
|
||||
if [ "$STARTED_FREECAD" = true ]; then
|
||||
kill $FREECAD_PID 2>/dev/null || true
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Verifying response..."
|
||||
|
||||
# Track test results
|
||||
TEST_PASSED=true
|
||||
DOCKER_CONFIRMED=false
|
||||
|
||||
# Check if the response indicates we're in a Docker container
|
||||
if echo "$CONTAINER_OUTPUT" | grep -q '"in_docker": true\|"in_docker":true'; then
|
||||
echo "✓ Response confirms running in Docker container"
|
||||
DOCKER_CONFIRMED=true
|
||||
elif echo "$CONTAINER_OUTPUT" | grep -q '"os_name": "Linux"\|"os_name":"Linux"'; then
|
||||
echo "✓ Response shows Linux OS (expected for Docker)"
|
||||
DOCKER_CONFIRMED=true
|
||||
else
|
||||
echo "⚠ Warning: Could not confirm Docker detection"
|
||||
TEST_PASSED=false
|
||||
fi
|
||||
|
||||
# Check for hostname (containers have short random hostnames)
|
||||
if echo "$CONTAINER_OUTPUT" | grep -q '"hostname"'; then
|
||||
HOSTNAME=$(echo "$CONTAINER_OUTPUT" | grep -o '"hostname"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1)
|
||||
echo "✓ Container hostname: $HOSTNAME"
|
||||
else
|
||||
echo "⚠ Warning: No hostname in response"
|
||||
fi
|
||||
|
||||
# Check we got a valid response (not an error)
|
||||
if echo "$CONTAINER_OUTPUT" | grep -q '"error"'; then
|
||||
# Check if it's just a "not connected" error (expected without proper init)
|
||||
if echo "$CONTAINER_OUTPUT" | grep -q 'Not connected\|Connection refused'; then
|
||||
echo " Note: FreeCAD connection test - bridge communication verified"
|
||||
else
|
||||
echo "✗ Error: Response contained an error"
|
||||
echo " $CONTAINER_OUTPUT" | tail -5
|
||||
TEST_PASSED=false
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Raw container output (last 20 lines):"
|
||||
echo "=========================================="
|
||||
echo "$CONTAINER_OUTPUT" | tail -20
|
||||
echo ""
|
||||
|
||||
# Cleanup
|
||||
if [ "$STARTED_FREECAD" = true ]; then
|
||||
echo "Step 5: Cleaning up..."
|
||||
kill $FREECAD_PID 2>/dev/null || true
|
||||
echo "✓ Stopped FreeCAD headless server"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
if [ "$TEST_PASSED" = true ] && [ "$DOCKER_CONFIRMED" = true ]; then
|
||||
echo "✓ PASSED: Docker integration test succeeded!"
|
||||
echo " - Container ran successfully"
|
||||
echo " - Confirmed running in Docker environment"
|
||||
elif [ "$TEST_PASSED" = true ]; then
|
||||
echo "⚠ PARTIAL: Docker integration test completed with warnings"
|
||||
echo " - Container ran successfully"
|
||||
echo " - Could not confirm Docker environment detection"
|
||||
else
|
||||
echo "✗ FAILED: Docker integration test had errors"
|
||||
echo " - Review the output above for details"
|
||||
fi
|
||||
echo "=========================================="
|
||||
|
||||
# Exit with appropriate code
|
||||
if [ "$TEST_PASSED" = false ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,21 @@
|
||||
# Documentation commands
|
||||
# Usage: just docs::build, just docs::serve, etc.
|
||||
|
||||
# Build documentation
|
||||
build:
|
||||
uv run mkdocs build
|
||||
|
||||
# Serve documentation locally
|
||||
serve:
|
||||
uv run mkdocs serve
|
||||
|
||||
# Build and open documentation in browser
|
||||
open:
|
||||
uv run mkdocs build
|
||||
@if [[ "$OSTYPE" == "darwin"* ]]; then \
|
||||
open site/index.html; \
|
||||
elif command -v xdg-open &> /dev/null; then \
|
||||
xdg-open site/index.html; \
|
||||
else \
|
||||
echo "Documentation built at: site/index.html"; \
|
||||
fi
|
||||
@@ -0,0 +1,354 @@
|
||||
# FreeCAD plugin and macro commands
|
||||
# Usage: just freecad::run-gui, just freecad::install-bridge-macro, etc.
|
||||
|
||||
# Project root directory (justfile_directory() returns the main justfile's directory)
|
||||
project_root := justfile_directory()
|
||||
|
||||
# =============================================================================
|
||||
# Running FreeCAD
|
||||
# =============================================================================
|
||||
|
||||
# Run MCP bridge server in FreeCAD headless mode
|
||||
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"
|
||||
|
||||
# Find FreeCADCmd executable based on OS
|
||||
FREECAD_CMD=""
|
||||
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# macOS: Check common locations
|
||||
if [[ -x "/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd" ]]; then
|
||||
FREECAD_CMD="/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd"
|
||||
elif [[ -x "$HOME/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd" ]]; then
|
||||
FREECAD_CMD="$HOME/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd"
|
||||
fi
|
||||
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
# Linux: Check common locations and PATH
|
||||
if command -v freecadcmd &> /dev/null; then
|
||||
FREECAD_CMD="freecadcmd"
|
||||
elif command -v FreeCADCmd &> /dev/null; then
|
||||
FREECAD_CMD="FreeCADCmd"
|
||||
elif [[ -x "/usr/bin/freecadcmd" ]]; then
|
||||
FREECAD_CMD="/usr/bin/freecadcmd"
|
||||
elif [[ -x "/usr/local/bin/freecadcmd" ]]; then
|
||||
FREECAD_CMD="/usr/local/bin/freecadcmd"
|
||||
elif [[ -x "/opt/freecad/bin/FreeCADCmd" ]]; then
|
||||
FREECAD_CMD="/opt/freecad/bin/FreeCADCmd"
|
||||
fi
|
||||
else
|
||||
# Windows: Check common locations
|
||||
if [[ -x "$PROGRAMFILES/FreeCAD 1.0/bin/FreeCADCmd.exe" ]]; then
|
||||
FREECAD_CMD="$PROGRAMFILES/FreeCAD 1.0/bin/FreeCADCmd.exe"
|
||||
elif [[ -x "$PROGRAMFILES/FreeCAD/bin/FreeCADCmd.exe" ]]; then
|
||||
FREECAD_CMD="$PROGRAMFILES/FreeCAD/bin/FreeCADCmd.exe"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$FREECAD_CMD" ]]; then
|
||||
echo "ERROR: FreeCADCmd not found!"
|
||||
echo ""
|
||||
echo "Please install FreeCAD or set FREECAD_CMD environment variable:"
|
||||
echo " export FREECAD_CMD=/path/to/FreeCADCmd"
|
||||
echo " just freecad::run-headless"
|
||||
echo ""
|
||||
echo "Common locations:"
|
||||
echo " macOS: /Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd"
|
||||
echo " Linux: /usr/bin/freecadcmd or freecadcmd"
|
||||
echo " Windows: C:\\Program Files\\FreeCAD 1.0\\bin\\FreeCADCmd.exe"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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 MCP bridge with custom FreeCAD path
|
||||
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"
|
||||
|
||||
if [[ ! -x "{{freecad_cmd}}" ]]; then
|
||||
echo "ERROR: FreeCADCmd not found or not executable: {{freecad_cmd}}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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-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"
|
||||
|
||||
if [[ ! -f "$STARTUP_SCRIPT" ]]; then
|
||||
echo "ERROR: Startup script not found: $STARTUP_SCRIPT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set environment variable for the project path
|
||||
export FREECAD_MCP_PROJECT_PATH="${PROJECT_DIR}/src"
|
||||
|
||||
# Find FreeCAD GUI executable based on OS
|
||||
FREECAD_GUI=""
|
||||
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# macOS: Use 'open' command for .app bundles
|
||||
if [[ -d "/Applications/FreeCAD.app" ]]; then
|
||||
FREECAD_GUI="/Applications/FreeCAD.app"
|
||||
elif [[ -d "$HOME/Applications/FreeCAD.app" ]]; then
|
||||
FREECAD_GUI="$HOME/Applications/FreeCAD.app"
|
||||
fi
|
||||
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
# Linux: Check common locations and PATH
|
||||
if command -v freecad &> /dev/null; then
|
||||
FREECAD_GUI="freecad"
|
||||
elif command -v FreeCAD &> /dev/null; then
|
||||
FREECAD_GUI="FreeCAD"
|
||||
elif [[ -x "/usr/bin/freecad" ]]; then
|
||||
FREECAD_GUI="/usr/bin/freecad"
|
||||
elif [[ -x "/usr/local/bin/freecad" ]]; then
|
||||
FREECAD_GUI="/usr/local/bin/freecad"
|
||||
elif [[ -x "/opt/freecad/bin/FreeCAD" ]]; then
|
||||
FREECAD_GUI="/opt/freecad/bin/FreeCAD"
|
||||
fi
|
||||
else
|
||||
# Windows: Check common locations
|
||||
if [[ -x "$PROGRAMFILES/FreeCAD 1.0/bin/FreeCAD.exe" ]]; then
|
||||
FREECAD_GUI="$PROGRAMFILES/FreeCAD 1.0/bin/FreeCAD.exe"
|
||||
elif [[ -x "$PROGRAMFILES/FreeCAD/bin/FreeCAD.exe" ]]; then
|
||||
FREECAD_GUI="$PROGRAMFILES/FreeCAD/bin/FreeCAD.exe"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$FREECAD_GUI" ]]; then
|
||||
rm -f "$STARTUP_SCRIPT"
|
||||
echo "ERROR: FreeCAD not found!"
|
||||
echo ""
|
||||
echo "Please install FreeCAD or use:"
|
||||
echo " just freecad::run-gui-custom /path/to/FreeCAD"
|
||||
echo ""
|
||||
echo "Common locations:"
|
||||
echo " macOS: /Applications/FreeCAD.app"
|
||||
echo " Linux: /usr/bin/freecad or freecad"
|
||||
echo " Windows: C:\\Program Files\\FreeCAD 1.0\\bin\\FreeCAD.exe"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting FreeCAD with MCP bridge..."
|
||||
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
|
||||
open -a "$FREECAD_GUI" --args "$STARTUP_SCRIPT"
|
||||
else
|
||||
# Linux/Windows: Run directly with script as argument
|
||||
"$FREECAD_GUI" "$STARTUP_SCRIPT" &
|
||||
fi
|
||||
|
||||
echo "FreeCAD is starting..."
|
||||
echo "The MCP bridge will start automatically once FreeCAD initializes."
|
||||
echo ""
|
||||
echo "Note: You can now start/restart your MCP client (Claude Code, etc.) to connect."
|
||||
|
||||
# Run FreeCAD GUI with custom path
|
||||
run-gui-custom freecad_path:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
PROJECT_DIR="{{project_root}}"
|
||||
|
||||
# Create a temporary startup script
|
||||
STARTUP_SCRIPT=$(mktemp /tmp/freecad_mcp_startup.XXXXXX.py)
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
from freecad_mcp.freecad_plugin.server import FreecadMCPPlugin
|
||||
plugin = FreecadMCPPlugin(
|
||||
host="localhost",
|
||||
port=9876,
|
||||
xmlrpc_port=9875,
|
||||
enable_xmlrpc=True,
|
||||
)
|
||||
plugin.start()
|
||||
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:
|
||||
FreeCAD.Console.PrintError(f"Failed to start MCP Bridge: {e}\\n")
|
||||
EOF
|
||||
|
||||
echo "Starting FreeCAD with MCP bridge..."
|
||||
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
|
||||
"{{freecad_path}}" "$STARTUP_SCRIPT" &
|
||||
fi
|
||||
|
||||
echo "FreeCAD is starting with MCP bridge..."
|
||||
|
||||
# =============================================================================
|
||||
# Macro Installation
|
||||
# =============================================================================
|
||||
|
||||
# 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
|
||||
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
|
||||
cp "${PROJECT_DIR}/macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro" "$MACRO_DIR/"
|
||||
|
||||
# Optionally copy the icon if it exists
|
||||
if [[ -f "${PROJECT_DIR}/macros/Cut_Object_for_Magnets/CutObjectForMagnets.svg" ]]; then
|
||||
cp "${PROJECT_DIR}/macros/Cut_Object_for_Magnets/CutObjectForMagnets.svg" "$MACRO_DIR/"
|
||||
fi
|
||||
|
||||
echo "CutObjectForMagnets macro installed to: $MACRO_DIR"
|
||||
echo ""
|
||||
echo "To use:"
|
||||
echo " 1. Start FreeCAD"
|
||||
echo " 2. Select an object to cut"
|
||||
echo " 3. Go to: Macro → Macros → CutObjectForMagnets → Execute"
|
||||
echo ""
|
||||
echo "For angled cuts:"
|
||||
echo " 1. Create a datum plane at your desired angle (Part Design → Create datum plane)"
|
||||
echo " 2. Select 'Model Plane' in the macro dialog"
|
||||
echo " 3. Choose your datum plane from the dropdown"
|
||||
|
||||
# Uninstall the CutObjectForMagnets macro
|
||||
uninstall-cut-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/CutObjectForMagnets.FCMacro"
|
||||
rm -f "$MACRO_DIR/CutObjectForMagnets.svg"
|
||||
echo "CutObjectForMagnets macro uninstalled"
|
||||
|
||||
# =============================================================================
|
||||
# Plugin Installation
|
||||
# =============================================================================
|
||||
|
||||
# Install the FreeCAD plugin to user's FreeCAD directory
|
||||
install-plugin:
|
||||
#!/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"
|
||||
|
||||
# Uninstall the FreeCAD plugin
|
||||
uninstall-plugin:
|
||||
#!/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
|
||||
rm -rf "$PLUGIN_DIR"
|
||||
echo "Plugin uninstalled"
|
||||
@@ -0,0 +1,80 @@
|
||||
# Code quality commands
|
||||
# Usage: just quality::check, just quality::format, etc.
|
||||
|
||||
# Run all pre-commit checks
|
||||
check:
|
||||
uv run pre-commit run --all-files
|
||||
|
||||
# Format code with ruff
|
||||
format:
|
||||
uv run ruff format src tests
|
||||
uv run ruff check --fix src tests
|
||||
|
||||
# Run linting
|
||||
lint:
|
||||
uv run ruff check src tests
|
||||
|
||||
# Run type checking
|
||||
typecheck:
|
||||
uv run mypy src
|
||||
|
||||
# Run security scanning (code vulnerabilities)
|
||||
security:
|
||||
uv run bandit -c pyproject.toml -r src
|
||||
uv pip list --format=freeze | uv run safety check --stdin
|
||||
|
||||
# Run spell checking
|
||||
spellcheck:
|
||||
uv run codespell src tests docs
|
||||
|
||||
# =============================================================================
|
||||
# Secrets Scanning
|
||||
# =============================================================================
|
||||
|
||||
# Run all secrets scanners
|
||||
secrets: secrets-gitleaks secrets-detect secrets-trufflehog # pragma: allowlist secret
|
||||
@echo "All secrets scans complete!"
|
||||
|
||||
# Run gitleaks secrets scanner
|
||||
secrets-gitleaks:
|
||||
uv run 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"
|
||||
|
||||
# Run detect-secrets scanner
|
||||
secrets-detect:
|
||||
uv run detect-secrets scan --baseline .secrets.baseline
|
||||
|
||||
# Audit detect-secrets baseline (interactive)
|
||||
secrets-audit:
|
||||
uv run detect-secrets audit .secrets.baseline
|
||||
|
||||
# Update detect-secrets baseline with new findings
|
||||
secrets-baseline-update:
|
||||
uv run detect-secrets scan --baseline .secrets.baseline --update
|
||||
|
||||
# Run trufflehog for verified secrets
|
||||
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
|
||||
|
||||
# =============================================================================
|
||||
# Markdown Linting
|
||||
# =============================================================================
|
||||
|
||||
# Lint all markdown files
|
||||
markdown-lint:
|
||||
uv run 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
|
||||
@@ -0,0 +1,69 @@
|
||||
# Testing commands
|
||||
# Usage: just testing::unit, just testing::integration, etc.
|
||||
|
||||
# Run unit tests only (excludes integration tests)
|
||||
unit:
|
||||
uv run pytest --ignore=tests/integration
|
||||
|
||||
# Run tests with coverage (excludes integration tests)
|
||||
cov:
|
||||
uv run pytest --ignore=tests/integration --cov=src/freecad_mcp --cov-report=term-missing --cov-report=html
|
||||
|
||||
# Run tests without slow markers (excludes integration tests)
|
||||
fast:
|
||||
uv run pytest --ignore=tests/integration -m "not slow"
|
||||
|
||||
# Run only integration tests (requires running FreeCAD MCP bridge)
|
||||
integration:
|
||||
uv run pytest tests/integration -v
|
||||
|
||||
# Run tests with verbose output (excludes integration tests)
|
||||
verbose:
|
||||
uv run pytest --ignore=tests/integration -v --tb=long
|
||||
|
||||
# Run all tests including integration (requires running FreeCAD MCP bridge)
|
||||
all:
|
||||
uv run pytest
|
||||
|
||||
# Run integration tests with automatic FreeCAD headless startup
|
||||
integration-auto:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "Starting FreeCAD headless server for integration tests..."
|
||||
echo ""
|
||||
|
||||
# Start FreeCAD headless in background
|
||||
just freecad::run-headless &
|
||||
FREECAD_PID=$!
|
||||
|
||||
# Give FreeCAD time to start the XML-RPC server
|
||||
echo "Waiting for FreeCAD MCP bridge to start..."
|
||||
sleep 5
|
||||
|
||||
# Check if the bridge is ready
|
||||
MAX_RETRIES=30
|
||||
RETRY_COUNT=0
|
||||
while ! curl -s http://localhost:9875 > /dev/null 2>&1; do
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
|
||||
echo "ERROR: FreeCAD MCP bridge did not start within timeout"
|
||||
kill $FREECAD_PID 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo " Waiting... ($RETRY_COUNT/$MAX_RETRIES)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "FreeCAD MCP bridge is ready!"
|
||||
echo ""
|
||||
|
||||
# Run integration tests
|
||||
TEST_EXIT_CODE=0
|
||||
uv run pytest tests/integration -v || TEST_EXIT_CODE=$?
|
||||
|
||||
# Stop FreeCAD
|
||||
echo ""
|
||||
echo "Stopping FreeCAD..."
|
||||
kill $FREECAD_PID 2>/dev/null || true
|
||||
|
||||
exit $TEST_EXIT_CODE
|
||||
@@ -0,0 +1,189 @@
|
||||
# FreeCAD MCP Server - Development Workflow Commands
|
||||
# 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 documentation::build - Documentation commands
|
||||
#
|
||||
# Or use the shortcut commands defined below.
|
||||
|
||||
# Import modules
|
||||
mod docker 'just/docker.just'
|
||||
mod quality 'just/quality.just'
|
||||
mod testing 'just/testing.just'
|
||||
mod freecad 'just/freecad.just'
|
||||
mod documentation 'just/documentation.just'
|
||||
|
||||
# Default recipe - show available commands
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# =============================================================================
|
||||
# Setup & Installation
|
||||
# =============================================================================
|
||||
|
||||
# Install all dependencies
|
||||
install:
|
||||
uv sync --all-extras
|
||||
|
||||
# Install pre-commit hooks
|
||||
setup-hooks:
|
||||
uv run pre-commit install
|
||||
uv run pre-commit install --hook-type commit-msg
|
||||
|
||||
# Full development environment setup
|
||||
setup: install setup-hooks
|
||||
@echo "Development environment ready!"
|
||||
|
||||
# Update all dependencies to latest versions
|
||||
update-deps:
|
||||
uv lock --upgrade
|
||||
uv sync --all-extras
|
||||
uv run pre-commit autoupdate
|
||||
|
||||
# =============================================================================
|
||||
# Shortcut Commands (aliases to module commands)
|
||||
# =============================================================================
|
||||
|
||||
# Run all pre-commit checks (shortcut for quality::check)
|
||||
check: (quality::check)
|
||||
|
||||
# Format code with ruff (shortcut for quality::format)
|
||||
format: (quality::format)
|
||||
|
||||
# Run linting (shortcut for quality::lint)
|
||||
lint: (quality::lint)
|
||||
|
||||
# Run type checking (shortcut for quality::typecheck)
|
||||
typecheck: (quality::typecheck)
|
||||
|
||||
# Run security scanning (shortcut for quality::security)
|
||||
security: (quality::security)
|
||||
|
||||
# Run spell checking (shortcut for quality::spellcheck)
|
||||
spellcheck: (quality::spellcheck)
|
||||
|
||||
# Run all secrets scanners (shortcut for quality::secrets)
|
||||
secrets: (quality::secrets)
|
||||
|
||||
# Lint markdown files (shortcut for quality::markdown-lint)
|
||||
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)
|
||||
|
||||
# Run tests with coverage (shortcut for testing::cov)
|
||||
test-cov: (testing::cov)
|
||||
|
||||
# Run fast tests (shortcut for testing::fast)
|
||||
test-fast: (testing::fast)
|
||||
|
||||
# Run integration tests (shortcut for testing::integration)
|
||||
test-integration: (testing::integration)
|
||||
|
||||
# Run tests with verbose output (shortcut for testing::verbose)
|
||||
test-verbose: (testing::verbose)
|
||||
|
||||
# Run all tests (shortcut for testing::all)
|
||||
test-all: (testing::all)
|
||||
|
||||
# Build documentation (shortcut for documentation::build)
|
||||
docs: (documentation::build)
|
||||
|
||||
# Serve documentation (shortcut for documentation::serve)
|
||||
docs-serve: (documentation::serve)
|
||||
|
||||
# =============================================================================
|
||||
# Running the MCP Server
|
||||
# =============================================================================
|
||||
|
||||
# Run the MCP server (stdio mode)
|
||||
run:
|
||||
uv run freecad-mcp
|
||||
|
||||
# Run the MCP server with debug logging
|
||||
run-debug:
|
||||
FREECAD_MCP_LOG_LEVEL=DEBUG uv run freecad-mcp
|
||||
|
||||
# Run in HTTP mode for remote access
|
||||
run-http port="8000":
|
||||
FREECAD_MCP_TRANSPORT=http FREECAD_MCP_PORT={{port}} uv run freecad-mcp
|
||||
|
||||
# =============================================================================
|
||||
# FreeCAD Shortcuts (aliases to freecad module)
|
||||
# =============================================================================
|
||||
|
||||
# Run FreeCAD GUI with MCP bridge (shortcut for freecad::run-gui)
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
# Run all quality checks and unit tests (use before committing)
|
||||
all: check test
|
||||
@echo "All checks passed!"
|
||||
|
||||
# Run integration tests with automatic FreeCAD headless startup
|
||||
integration: (testing::integration-auto)
|
||||
|
||||
# Run all quality checks and ALL tests including integration
|
||||
all-integration: check test integration
|
||||
@echo "All checks and integration tests passed!"
|
||||
|
||||
# Full CI pipeline (unit tests only)
|
||||
ci: check test-cov
|
||||
@echo "CI pipeline complete!"
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf .pytest_cache .mypy_cache .ruff_cache .coverage htmlcov dist build
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
|
||||
# =============================================================================
|
||||
# Development Helpers
|
||||
# =============================================================================
|
||||
|
||||
# Open Python REPL with project modules available
|
||||
repl:
|
||||
uv run python -c "import freecad_mcp; print('FreeCAD MCP loaded')" && uv run python
|
||||
|
||||
# Show project structure
|
||||
tree:
|
||||
tree -I '__pycache__|*.egg-info|.git|.mypy_cache|.pytest_cache|.ruff_cache|htmlcov|dist|build' -a
|
||||
|
||||
# Validate project configuration
|
||||
validate:
|
||||
uv pip check
|
||||
uv run validate-pyproject pyproject.toml
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background -->
|
||||
<rect width="64" height="64" fill="#2c3e50" rx="4"/>
|
||||
|
||||
<!-- Top piece of cut object -->
|
||||
<g id="top-piece">
|
||||
<!-- Main body -->
|
||||
<rect x="12" y="8" width="40" height="18" fill="#3498db" stroke="#2980b9" stroke-width="1.5"/>
|
||||
<!-- Connector holes (visible from bottom) -->
|
||||
<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 -->
|
||||
<rect x="12" y="38" width="40" height="18" fill="#95a5a6" stroke="#7f8c8d" stroke-width="1.5"/>
|
||||
<!-- 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"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,630 @@
|
||||
# Cut Object for Magnets - FreeCAD Macro
|
||||
|
||||

|
||||
|
||||
**Version:** 1.0.0
|
||||
**FreeCAD Version:** 0.19 or later
|
||||
**License:** MIT
|
||||
|
||||
## Overview
|
||||
|
||||
This FreeCAD macro intelligently cuts 3D objects along a plane and automatically places magnet holes (for magnets, dowels, pins, etc.) with built-in surface penetration detection. Unlike simple cutting tools, this macro ensures magnet holes won't accidentally break through the outer surface of your object.
|
||||
|
||||
Perfect for creating multi-part prints that snap together with magnets or alignment pins!
|
||||
|
||||
## Quick Visual Guide
|
||||
|
||||
### Method 1: Preset Planes (Simple)
|
||||
|
||||
```text
|
||||
Select Object → Run Macro → Choose XY/XZ/YZ → Set Offset → Execute
|
||||
```
|
||||
|
||||
Use for: Axis-aligned cuts, quick splits, standard orientations
|
||||
|
||||
### Method 2: Model Planes (Advanced - ANY Angle)
|
||||
|
||||
```text
|
||||
Create Datum Plane (angled as needed) → Select Object → Run Macro →
|
||||
Choose "Model Plane" → Select Your Plane → Execute
|
||||
```
|
||||
|
||||
Use for: Angled cuts, following model geometry, complex orientations
|
||||
|
||||
### Quick Tip: Select Both Object AND Plane Together
|
||||
|
||||
```text
|
||||
Select Object + Plane (Ctrl+Click) → Run Macro → Plane auto-selected!
|
||||
```
|
||||
|
||||
The macro automatically detects when you select both an object and a plane together.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Smart Surface Detection** - Automatically skips holes that would penetrate the object's outer surface
|
||||
- **Smart Repositioning** - When a hole would penetrate, tries nearby positions (further inward or along perimeter)
|
||||
- **Dual-Part Validation** - Validates each hole position works for BOTH parts before creating any holes
|
||||
- **Minimum Spacing** - Ensures holes are at least 2x diameter apart (one hole width between them)
|
||||
- **Automatic Alignment** - Magnet holes on both pieces are perfectly aligned
|
||||
- **Flexible Plane Selection** - Use preset planes (XY/XZ/YZ) OR any datum plane/face from your model
|
||||
- **Angled Cuts** - Cut along any angle by selecting a datum plane
|
||||
- **Even Distribution** - Holes are evenly spaced around the perimeter of the cut face
|
||||
- **Count-Based Placement** - Specify exact number of holes (default: 6) for precise control
|
||||
- **Safety Clearance** - Maintains minimum distance from outer surfaces
|
||||
- **Visual Feedback** - Progress bar and status messages
|
||||
- **Non-Destructive** - Original object is hidden, not deleted
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### macOS Installation (Detailed)
|
||||
|
||||
#### Step 1: Locate Your FreeCAD Macros Folder
|
||||
|
||||
The macros folder location on macOS:
|
||||
|
||||
```text
|
||||
~/Library/Application Support/FreeCAD/Macro/
|
||||
```
|
||||
|
||||
**How to access it:**
|
||||
|
||||
**Option A - Finder (Recommended):**
|
||||
|
||||
1. Open **Finder**
|
||||
1. Press `Cmd + Shift + G` (Go to Folder)
|
||||
1. Paste: `~/Library/Application Support/FreeCAD/Macro/`
|
||||
1. Press **Enter**
|
||||
|
||||
**Option B - Terminal:**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/Library/Application\ Support/FreeCAD/Macro/
|
||||
open ~/Library/Application\ Support/FreeCAD/Macro/
|
||||
```
|
||||
|
||||
**Option C - From FreeCAD:**
|
||||
|
||||
1. Open FreeCAD
|
||||
1. Go to **Macro → Macros...**
|
||||
1. Note the path shown at the top of the dialog
|
||||
1. Click **User macros location** to open in Finder
|
||||
|
||||
#### Step 2: Install the Macro File
|
||||
|
||||
1. Save the macro file as `CutObjectForMagnets.FCMacro` in the macros folder
|
||||
1. (Optional) Save the icon as `CutObjectForMagnets.svg` in the same folder
|
||||
|
||||
#### Step 3: Verify Installation
|
||||
|
||||
1. Open FreeCAD
|
||||
1. Go to **Macro → Macros...**
|
||||
1. You should see "CutObjectForMagnets" in the list
|
||||
1. (Optional) Click **Edit** to view the macro code
|
||||
|
||||
#### Step 4: Create a Toolbar Button (Optional but Recommended)
|
||||
|
||||
1. Go to **Macro → Macros...**
|
||||
1. Select **CutObjectForMagnets**
|
||||
1. Click **Create** (toolbar button icon)
|
||||
1. Choose the icon file (`CutObjectForMagnets.svg`) if you saved it
|
||||
1. The macro will now appear in your toolbar for quick access
|
||||
|
||||
---
|
||||
|
||||
### Linux Installation
|
||||
|
||||
```bash
|
||||
# Create macros directory if it doesn't exist
|
||||
mkdir -p ~/.FreeCAD/Macro/
|
||||
|
||||
# Copy macro file
|
||||
cp CutObjectForMagnets.FCMacro ~/.FreeCAD/Macro/
|
||||
|
||||
# (Optional) Copy icon
|
||||
cp CutObjectForMagnets.svg ~/.FreeCAD/Macro/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Windows Installation
|
||||
|
||||
1. Navigate to: `%APPDATA%\FreeCAD\Macro\`
|
||||
1. Copy `CutObjectForMagnets.FCMacro` to this folder
|
||||
1. (Optional) Copy `CutObjectForMagnets.svg` to the same folder
|
||||
|
||||
---
|
||||
|
||||
## How to Use
|
||||
|
||||
### Quick Start Guide
|
||||
|
||||
#### Step 1: Prepare Your Model
|
||||
|
||||
1. Open your model in FreeCAD (or create/import one)
|
||||
1. Ensure the object is a **solid** (not a shell or surface)
|
||||
1. **Select the object** in the 3D view or tree view
|
||||
|
||||
**Tip:** If you imported an STL, it should already be a valid solid. If you created the model in FreeCAD, make sure you created a solid object in the Part workbench.
|
||||
|
||||
#### Step 2: Launch the Macro
|
||||
|
||||
- **From Macro Menu:** Macro → Macros... → Select "CutObjectForMagnets" → Execute
|
||||
- **From Toolbar:** Click the macro icon (if you created a toolbar button)
|
||||
|
||||
#### Step 3: Configure Cut Plane
|
||||
|
||||
The dialog will open with several configuration sections.
|
||||
|
||||
**Plane Type Settings:**
|
||||
|
||||
You have two options for defining your cut plane:
|
||||
|
||||
**Option 1: Preset Plane** (Simple, axis-aligned cuts)
|
||||
|
||||
- **Plane:** Choose orientation
|
||||
|
||||
- `XY` - Horizontal cut (most common)
|
||||
- `XZ` - Vertical cut (front-to-back)
|
||||
- `YZ` - Vertical cut (left-to-right)
|
||||
|
||||
- **Offset:** Position of the cut plane from origin (in mm)
|
||||
|
||||
- `0` - Cut through the origin
|
||||
- Positive values move the plane in the positive axis direction
|
||||
- Negative values move it in the negative axis direction
|
||||
|
||||
**Finding the Right Offset:**
|
||||
|
||||
1. Note your object's bounding box dimensions (visible in FreeCAD)
|
||||
1. If your object is centered at origin and you want to cut in half:
|
||||
- Use offset = `0`
|
||||
1. If your object is positioned elsewhere:
|
||||
- Look at the coordinate where you want to cut
|
||||
- Use that value as the offset
|
||||
|
||||
**Option 2: Model Plane** (Advanced, angled cuts)
|
||||
|
||||
Select any datum plane or planar face from your model:
|
||||
|
||||
**Using Datum Planes:**
|
||||
|
||||
1. Create a datum plane first (if not already in model):
|
||||
|
||||
- Go to **Part Design** workbench
|
||||
- Click **Create datum plane** (or Part → Datum → Datum Plane)
|
||||
- Position and angle the plane where you want to cut
|
||||
- Exit datum plane creation
|
||||
|
||||
1. In the macro dialog:
|
||||
|
||||
- Select **"Model Plane"** from Plane Type
|
||||
- Choose your datum plane from the dropdown
|
||||
- The offset field is disabled (plane position defines the cut)
|
||||
|
||||
**Using Object Faces:**
|
||||
|
||||
- Any planar face on any object in your document can be used as a cut plane
|
||||
- Select from the dropdown: "Face: [ObjectName] (Face1, Face2, etc.)"
|
||||
- Useful for cutting along existing geometry
|
||||
|
||||
**Examples of Model Plane Cuts:**
|
||||
|
||||
- Cut at 45° angle - Create datum plane rotated 45° around X or Y axis
|
||||
- Cut following surface contour - Select a planar face on reference geometry
|
||||
- Cut perpendicular to cylinder axis - Use cylinder end face
|
||||
- Complex multi-angle cuts - Create multiple datum planes for different cuts
|
||||
|
||||
#### Step 4: Configure Magnet Holes
|
||||
|
||||
**Hole Parameters:**
|
||||
|
||||
- **Diameter:** The diameter of your magnet (mm)
|
||||
|
||||
- For 6mm magnets: Use `6.2mm` (adds 0.2mm clearance)
|
||||
- For 3mm dowels: Use `3.1mm` (adds 0.1mm clearance)
|
||||
- For friction fit: Use exact diameter or slightly smaller
|
||||
|
||||
- **Depth:** How deep holes go into each piece (mm)
|
||||
|
||||
- For magnets: Use magnet thickness + 0.5mm
|
||||
- For dowel pins: Usually half the dowel length
|
||||
- **Important:** Each piece gets this depth
|
||||
|
||||
- **Number of Holes:** Total number of magnet holes to create (default: 6)
|
||||
|
||||
- Holes are evenly distributed around the perimeter of the cut face
|
||||
- More holes = stronger joint, but requires more magnets
|
||||
- Recommended: `4-6` for small objects, `8-12` for large objects
|
||||
|
||||
- **Edge Clearance (Preferred):** Ideal distance from hole edge to outer surface (mm)
|
||||
|
||||
- Default: `2mm` - holes are initially placed with this clearance
|
||||
- This is the distance the macro tries to maintain for optimal part strength
|
||||
- Larger values = more material around holes = stronger parts
|
||||
|
||||
- **Edge Clearance (Minimum):** Absolute minimum acceptable clearance (mm)
|
||||
|
||||
- Default: `0.5mm` - the smallest allowable clearance
|
||||
- When smart repositioning can't place a hole at preferred clearance, it will try progressively smaller clearances down to this minimum
|
||||
- Prevents holes from getting too close to outer surfaces
|
||||
- For thin-walled objects: Increase this value to prevent breakthrough
|
||||
|
||||
#### Step 5: Execute the Cut
|
||||
|
||||
1. Review all parameters
|
||||
1. Click **"Execute Cut"**
|
||||
1. Watch the progress bar
|
||||
1. Check status messages for any skipped holes
|
||||
|
||||
#### Step 6: Review Results
|
||||
|
||||
The macro creates two new objects:
|
||||
|
||||
- `[ObjectName]_Bottom` - Lower piece with holes
|
||||
- `[ObjectName]_Top` - Upper piece with holes
|
||||
|
||||
Your original object is **hidden** (not deleted) - you can show it again from the tree view if needed.
|
||||
|
||||
---
|
||||
|
||||
## Parameter Reference Guide
|
||||
|
||||
### Understanding Edge Clearance
|
||||
|
||||
The macro uses a **dual clearance system** to balance ideal hole placement with flexibility:
|
||||
|
||||
- **Preferred Clearance (2mm default):** Initial hole placement uses this value
|
||||
- **Minimum Clearance (0.5mm default):** Fallback when preferred clearance fails
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Holes are first placed using the preferred clearance
|
||||
1. If a hole fails the safety check at preferred clearance, smart repositioning kicks in
|
||||
1. The macro tries progressively smaller clearances (from preferred down to minimum)
|
||||
1. Only if all clearance levels fail does the macro try moving the hole position
|
||||
|
||||
The safety check ensures:
|
||||
|
||||
```text
|
||||
hole_radius + depth + clearance < distance_to_nearest_surface
|
||||
```
|
||||
|
||||
**Visual Example:**
|
||||
|
||||
```text
|
||||
[Outer Surface]
|
||||
|
|
||||
clearance (preferred: 2mm, min: 0.5mm)
|
||||
|
|
||||
[Hole boundary] ← Must not touch outer surface
|
||||
|
|
||||
actual hole
|
||||
```
|
||||
|
||||
If a hole would violate even the minimum clearance, it's automatically skipped and you'll see a warning in the console.
|
||||
|
||||
### Calculating Depth for Magnets
|
||||
|
||||
For magnets that should be flush or recessed:
|
||||
|
||||
```text
|
||||
depth = magnet_thickness + recess_amount
|
||||
|
||||
Examples:
|
||||
- 2mm thick magnet, flush: depth = 2.5mm (0.5mm tolerance)
|
||||
- 3mm thick magnet, 0.5mm recess: depth = 3.5mm + 0.5mm = 4mm
|
||||
```
|
||||
|
||||
**Important:** Total depth in both pieces should accommodate the magnet fully:
|
||||
|
||||
```text
|
||||
total_depth = depth_bottom + depth_top
|
||||
total_depth should be >= magnet_thickness
|
||||
```
|
||||
|
||||
### Recommended Hole Count Guidelines
|
||||
|
||||
| Object Size | Recommended Holes | Notes |
|
||||
| ----------------- | ----------------- | ---------------------------------- |
|
||||
| Small (\<50mm) | 4-6 | Fewer holes for small surfaces |
|
||||
| Medium (50-150mm) | 6-10 | Default of 6 works well |
|
||||
| Large (>150mm) | 10-16 | More holes for stronger connection |
|
||||
| Thin-walled | 4-6 | Use smaller diameter holes |
|
||||
|
||||
---
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### Case 1: Large Print Split for Bed Size
|
||||
|
||||
**Scenario:** 300mm diameter object, need to split for 250mm print bed
|
||||
|
||||
**Settings:**
|
||||
|
||||
```text
|
||||
Plane: XY
|
||||
Offset: 0 (if centered) or half the object height
|
||||
Diameter: 6.2mm (for 6×2mm magnets)
|
||||
Depth: 2.5mm
|
||||
Number of Holes: 12
|
||||
Clearance: 3mm
|
||||
```
|
||||
|
||||
**Result:** Two pieces that stack vertically with 12 evenly-spaced magnets for alignment
|
||||
|
||||
---
|
||||
|
||||
### Case 2: Modular Terrain Tiles
|
||||
|
||||
**Scenario:** 100×100mm terrain tiles with magnetic edges
|
||||
|
||||
**Settings:**
|
||||
|
||||
```text
|
||||
Plane: XY or YZ (depending on desired split)
|
||||
Offset: 50mm (half the tile dimension)
|
||||
Diameter: 3.2mm (for 3×1mm magnets)
|
||||
Depth: 1.5mm
|
||||
Number of Holes: 6
|
||||
Clearance: 2mm
|
||||
```
|
||||
|
||||
**Result:** Tiles that connect magnetically at edges with 6 evenly-spaced magnets
|
||||
|
||||
---
|
||||
|
||||
### Case 3: Dowel Pin Alignment for Large Parts
|
||||
|
||||
**Scenario:** Splitting a large miniature or sculpture
|
||||
|
||||
**Settings:**
|
||||
|
||||
```text
|
||||
Plane: XY
|
||||
Offset: [at desired split point]
|
||||
Diameter: 3.1mm (for 3mm brass pins)
|
||||
Depth: 8mm (16mm pins, 8mm per side)
|
||||
Number of Holes: 4
|
||||
Clearance: 3mm
|
||||
```
|
||||
|
||||
**Result:** Precise alignment with 4 evenly-spaced removable pins
|
||||
|
||||
---
|
||||
|
||||
### Case 4: Angled Cut with Datum Plane (45° Wedge)
|
||||
|
||||
**Scenario:** Splitting a wedge-shaped console at a 45° angle
|
||||
|
||||
**Preparation:**
|
||||
|
||||
1. Switch to **Part Design** workbench
|
||||
1. Create datum plane:
|
||||
- Click **Create datum plane** (or Part → Datum → Create datum plane)
|
||||
- In the dialog:
|
||||
- Attachment mode: "Translate origin"
|
||||
- Angle: Rotate 45° around X-axis
|
||||
- Position: Move to desired cut location
|
||||
- Click **OK**
|
||||
1. Name it "CutPlane45" (optional but helpful)
|
||||
|
||||
**Settings:**
|
||||
|
||||
```text
|
||||
Plane Type: Model Plane
|
||||
Model Plane: Plane: CutPlane45
|
||||
Diameter: 6.2mm (for 6×2mm magnets)
|
||||
Depth: 3mm
|
||||
Number of Holes: 8
|
||||
Clearance: 3mm
|
||||
```
|
||||
|
||||
**Result:** Clean 45° angled cut with 8 evenly-spaced magnet holes
|
||||
|
||||
**Pro Tip:** You can visually see where the cut will be by looking at the datum plane position before running the macro!
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error: "Please select an object to cut"
|
||||
|
||||
**Solution:** Click on your object in the 3D view before running the macro.
|
||||
|
||||
---
|
||||
|
||||
### Error: "Selected object does not have a shape"
|
||||
|
||||
**Solution:** You selected a group or annotation. Select the actual 3D object (should be under Part or Body in the tree).
|
||||
|
||||
---
|
||||
|
||||
### Error: "Failed to cut object"
|
||||
|
||||
**Possible causes:**
|
||||
|
||||
1. **Invalid mesh** - The object has geometry errors
|
||||
|
||||
- Try: Edit → Preferences → Part Design → "Automatically refine model after boolean operation"
|
||||
- Or manually: Part → Refine Shape
|
||||
|
||||
1. **Offset is outside object bounds** - The cut plane doesn't intersect the object
|
||||
|
||||
- Check your object's position and bounding box
|
||||
- Adjust offset to actually pass through the object
|
||||
|
||||
1. **Model plane doesn't intersect** - The selected datum plane or face doesn't pass through the object
|
||||
|
||||
- Verify the plane position in the 3D view
|
||||
- Create a new datum plane that actually cuts through the object
|
||||
|
||||
---
|
||||
|
||||
### Error: "No planes available"
|
||||
|
||||
**Solution:** You selected "Model Plane" but no datum planes or planar faces exist in the document.
|
||||
|
||||
**Fix:**
|
||||
|
||||
1. Create a datum plane:
|
||||
- **Part Design → Create datum plane**
|
||||
- Position and angle as needed
|
||||
- Click OK
|
||||
1. Or switch to "Preset Plane" mode
|
||||
1. Re-run the macro
|
||||
|
||||
---
|
||||
|
||||
### Warning: "Skipping hole at [position] - would penetrate surface"
|
||||
|
||||
**This is normal!** The macro is protecting you from holes that would break through.
|
||||
|
||||
**If too many holes are skipped:**
|
||||
|
||||
1. **Increase edge clearance** (e.g., from 2mm to 4mm)
|
||||
1. **Increase spacing** (fewer holes, but safer)
|
||||
1. **Reduce hole depth** (less likely to penetrate)
|
||||
1. **Check your object** - might be too thin for the hole size
|
||||
|
||||
---
|
||||
|
||||
### Error: "No valid hole positions found"
|
||||
|
||||
**Possible causes:**
|
||||
|
||||
1. **Cut surface too small** - Not enough room for even one hole
|
||||
|
||||
- Solution: Reduce spacing or hole diameter
|
||||
|
||||
1. **Object too thin** - All positions would penetrate
|
||||
|
||||
- Solution: Reduce hole depth or increase clearance less aggressively
|
||||
|
||||
1. **Complex geometry** - Cut surface is irregular
|
||||
|
||||
- Solution: Manually place holes in FreeCAD after cutting
|
||||
|
||||
---
|
||||
|
||||
## Advanced Tips & Best Practices
|
||||
|
||||
### Creating Perfect Datum Planes
|
||||
|
||||
**For Angled Cuts:**
|
||||
|
||||
1. **Simple Rotation Method:**
|
||||
|
||||
```text
|
||||
Part Design → Create datum plane
|
||||
- Reference: XY plane (or any base plane)
|
||||
- Attachment offset → Rotation:
|
||||
- Around X: Tilts forward/back
|
||||
- Around Y: Tilts left/right
|
||||
- Around Z: Spins horizontally
|
||||
- Position: Translation Z/Y/X to move the plane
|
||||
```
|
||||
|
||||
1. **Align to Edges/Vertices:**
|
||||
|
||||
```text
|
||||
Part Design → Create datum plane
|
||||
- Attachment mode: "Three points"
|
||||
- Select 3 points on your model
|
||||
- Plane will pass through all three points
|
||||
```
|
||||
|
||||
### Choosing the Right Clearance
|
||||
|
||||
The dual clearance system gives you fine-grained control:
|
||||
|
||||
**Preferred Clearance (default 2mm):**
|
||||
|
||||
- Sets how far from edges holes are initially placed
|
||||
- Increase for stronger parts (more material around holes)
|
||||
- Decrease if you need holes closer to edges
|
||||
|
||||
**Minimum Clearance (default 0.5mm):**
|
||||
|
||||
- The absolute minimum acceptable distance from edges
|
||||
- Increase for thin-walled objects to prevent breakthrough
|
||||
- Should always be ≤ preferred clearance
|
||||
|
||||
**Guidelines by object type:**
|
||||
|
||||
- **Thin-walled objects** (2-4mm walls): Preferred=2mm, Minimum=1mm
|
||||
- **Thick objects** (>10mm walls): Preferred=3mm, Minimum=0.5mm (defaults work well)
|
||||
- **Irregular shapes:** Preferred=4-5mm, Minimum=2mm to be safe
|
||||
|
||||
### Magnet Installation Tips
|
||||
|
||||
After printing and cutting:
|
||||
|
||||
1. Test fit magnets - they should slide in smoothly
|
||||
1. If too tight: Use a drill bit to clean out the holes slightly
|
||||
1. If too loose: Use CA glue or epoxy to secure
|
||||
1. **Check polarity!** - Mark magnet orientation before gluing
|
||||
|
||||
---
|
||||
|
||||
## Technical Details
|
||||
|
||||
### How Surface Penetration Detection Works
|
||||
|
||||
For each potential hole position, the macro:
|
||||
|
||||
1. Creates a test cylinder with radius = `(diameter/2) + clearance`
|
||||
1. Extends the cylinder to depth = `hole_depth + clearance`
|
||||
1. Performs boolean intersection with the part
|
||||
1. If intersection volume < 99% of test cylinder volume → hole would penetrate → skip it
|
||||
|
||||
This ensures holes only go where they're safe!
|
||||
|
||||
### Coordinate System
|
||||
|
||||
- **Origin (0,0,0):** FreeCAD document origin
|
||||
- **Cut planes** pass through a point at the specified offset:
|
||||
- XY plane: Point = (0, 0, offset)
|
||||
- XZ plane: Point = (0, offset, 0)
|
||||
- YZ plane: Point = (offset, 0, 0)
|
||||
|
||||
### Hole Direction
|
||||
|
||||
- **Bottom piece:** Holes point UP (toward cut plane)
|
||||
- **Top piece:** Holes point DOWN (toward cut plane)
|
||||
- Both sets align perfectly at the cut interface
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
### v1.0.0 (2025-01-01)
|
||||
|
||||
- Initial release
|
||||
- XY, XZ, YZ preset plane support
|
||||
- Model plane support (datum planes and planar faces)
|
||||
- Angled cut capability via datum planes
|
||||
- Surface penetration detection
|
||||
- Configurable hole parameters
|
||||
- Progress tracking
|
||||
- Dual-part validation
|
||||
- Minimum spacing enforcement (2x diameter)
|
||||
- Smart hole repositioning
|
||||
- Object selection combo box
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License - Free to use, modify, and distribute.
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
|
||||
Created for the FreeCAD community to make multi-part 3D printing easier and more reliable.
|
||||
|
||||
**Inspiration:** PrusaSlicer's cut tool, but with reliable export and better surface detection.
|
||||
@@ -0,0 +1,138 @@
|
||||
# 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-mcp", "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.
|
||||
@@ -0,0 +1,69 @@
|
||||
"""FreeCAD Macro: Start MCP Bridge Server.
|
||||
|
||||
Start the MCP bridge server for AI assistant integration with FreeCAD.
|
||||
|
||||
Version: 1.0.0
|
||||
Author: FreeCAD MCP Project
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background -->
|
||||
<rect width="64" height="64" fill="#2c3e50" rx="4"/>
|
||||
|
||||
<!-- FreeCAD logo representation (left side) - simplified gear/cog -->
|
||||
<g id="freecad-icon" transform="translate(10, 22)">
|
||||
<!-- Gear body -->
|
||||
<circle cx="10" cy="10" r="8" fill="#e74c3c" stroke="#c0392b" stroke-width="1"/>
|
||||
<!-- Gear teeth -->
|
||||
<rect x="7" y="0" width="6" height="3" fill="#e74c3c"/>
|
||||
<rect x="7" y="17" width="6" height="3" fill="#e74c3c"/>
|
||||
<rect x="0" y="7" width="3" height="6" fill="#e74c3c"/>
|
||||
<rect x="17" y="7" width="3" height="6" fill="#e74c3c"/>
|
||||
<!-- Inner circle -->
|
||||
<circle cx="10" cy="10" r="4" fill="#2c3e50"/>
|
||||
</g>
|
||||
|
||||
<!-- Claude/AI representation (right side) - stylized brain/network -->
|
||||
<g id="claude-icon" transform="translate(44, 22)">
|
||||
<!-- Main circle -->
|
||||
<circle cx="10" cy="10" r="8" fill="#d4a574" stroke="#b8956a" stroke-width="1"/>
|
||||
<!-- Network nodes -->
|
||||
<circle cx="10" cy="6" r="2" fill="#2c3e50"/>
|
||||
<circle cx="6" cy="12" r="2" fill="#2c3e50"/>
|
||||
<circle cx="14" cy="12" r="2" fill="#2c3e50"/>
|
||||
<!-- Network connections -->
|
||||
<line x1="10" y1="6" x2="6" y2="12" stroke="#2c3e50" stroke-width="1"/>
|
||||
<line x1="10" y1="6" x2="14" y2="12" stroke="#2c3e50" stroke-width="1"/>
|
||||
<line x1="6" y1="12" x2="14" y2="12" stroke="#2c3e50" stroke-width="1"/>
|
||||
</g>
|
||||
|
||||
<!-- Bridge/Connection arrows -->
|
||||
<g id="bridge">
|
||||
<!-- Arrow from FreeCAD to Claude -->
|
||||
<line x1="28" y1="28" x2="40" y2="28" stroke="#27ae60" stroke-width="2"/>
|
||||
<polygon points="40,28 36,25 36,31" fill="#27ae60"/>
|
||||
|
||||
<!-- Arrow from Claude to FreeCAD -->
|
||||
<line x1="40" y1="36" x2="28" y2="36" stroke="#3498db" stroke-width="2"/>
|
||||
<polygon points="28,36 32,33 32,39" fill="#3498db"/>
|
||||
</g>
|
||||
|
||||
<!-- MCP text label -->
|
||||
<text x="32" y="56" font-family="Arial, sans-serif" font-size="8" font-weight="bold"
|
||||
fill="#ecf0f1" text-anchor="middle">MCP</text>
|
||||
|
||||
<!-- Connection status indicator (green dot) -->
|
||||
<circle cx="32" cy="10" r="4" fill="#27ae60" stroke="#1e8449" stroke-width="1"/>
|
||||
<!-- Pulse ring animation hint -->
|
||||
<circle cx="32" cy="10" r="6" fill="none" stroke="#27ae60" stroke-width="0.5" opacity="0.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
+75
@@ -0,0 +1,75 @@
|
||||
site_name: FreeCAD MCP Server
|
||||
site_description: MCP server for FreeCAD integration with AI assistants
|
||||
site_url: https://github.com/spkane/freecad-mcp
|
||||
repo_url: https://github.com/spkane/freecad-mcp
|
||||
repo_name: spkane/freecad-mcp
|
||||
|
||||
theme:
|
||||
name: material
|
||||
palette:
|
||||
- scheme: default
|
||||
primary: blue
|
||||
accent: blue
|
||||
toggle:
|
||||
icon: material/brightness-7
|
||||
name: Switch to dark mode
|
||||
- scheme: slate
|
||||
primary: blue
|
||||
accent: blue
|
||||
toggle:
|
||||
icon: material/brightness-4
|
||||
name: Switch to light mode
|
||||
features:
|
||||
- content.code.copy
|
||||
- content.code.annotate
|
||||
- navigation.instant
|
||||
- navigation.tracking
|
||||
- navigation.tabs
|
||||
- navigation.sections
|
||||
- toc.integrate
|
||||
- search.suggest
|
||||
- search.highlight
|
||||
|
||||
markdown_extensions:
|
||||
- pymdownx.highlight:
|
||||
anchor_linenums: true
|
||||
- pymdownx.superfences
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.snippets
|
||||
- admonition
|
||||
- pymdownx.details
|
||||
- attr_list
|
||||
- md_in_html
|
||||
- tables
|
||||
- toc:
|
||||
permalink: true
|
||||
|
||||
plugins:
|
||||
- search
|
||||
- mkdocstrings:
|
||||
handlers:
|
||||
python:
|
||||
options:
|
||||
docstring_style: google
|
||||
show_source: true
|
||||
show_root_heading: true
|
||||
show_category_heading: true
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Getting Started:
|
||||
- Installation: getting-started/installation.md
|
||||
- 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
|
||||
- API Reference:
|
||||
- Server: api/server.md
|
||||
- Bridge: api/bridge.md
|
||||
- Config: api/config.md
|
||||
- Development:
|
||||
- Contributing: development/contributing.md
|
||||
- Architecture: development/architecture.md
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "freecad-robust-mcp"
|
||||
version = "0.1.0"
|
||||
description = "MCP (Model Context Protocol) server for FreeCAD integration with Claude Code and other AI assistants"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
requires-python = ">=3.11"
|
||||
authors = [
|
||||
{ name = "Sean P. Kane", email = "spkane@gmail.com" }
|
||||
]
|
||||
maintainers = [
|
||||
{ name = "Sean P. Kane", email = "spkane@gmail.com" }
|
||||
]
|
||||
keywords = [
|
||||
"freecad",
|
||||
"mcp",
|
||||
"model-context-protocol",
|
||||
"claude",
|
||||
"claude-code",
|
||||
"ai",
|
||||
"llm",
|
||||
"cad",
|
||||
"3d-modeling",
|
||||
"3d-printing",
|
||||
"parametric-design",
|
||||
"automation",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"Framework :: AsyncIO",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Manufacturing",
|
||||
"Intended Audience :: Science/Research",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Natural Language :: English",
|
||||
"Operating System :: OS Independent",
|
||||
"Operating System :: MacOS",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Operating System :: Microsoft :: Windows",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Topic :: Scientific/Engineering",
|
||||
"Topic :: Scientific/Engineering :: Visualization",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"mcp>=1.25.0",
|
||||
"pydantic>=2.10.0",
|
||||
"pydantic-settings>=2.7.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3.0",
|
||||
"pytest-asyncio>=0.25.0",
|
||||
"pytest-cov>=6.0.0",
|
||||
"pytest-timeout>=2.3.0",
|
||||
"mypy>=1.14.0",
|
||||
"ruff>=0.8.0",
|
||||
"bandit[toml]>=1.8.0",
|
||||
"safety>=3.2.0",
|
||||
"pre-commit>=4.0.0",
|
||||
"mkdocs>=1.6.0",
|
||||
"mkdocs-material>=9.5.0",
|
||||
"mkdocstrings[python]>=0.27.0",
|
||||
"md-toc>=9.0.0",
|
||||
"twine>=6.0.0",
|
||||
"build>=1.2.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
freecad-mcp = "freecad_mcp.server:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/spkane/freecad-mcp"
|
||||
Documentation = "https://github.com/spkane/freecad-mcp#readme"
|
||||
Repository = "https://github.com/spkane/freecad-mcp.git"
|
||||
Issues = "https://github.com/spkane/freecad-mcp/issues"
|
||||
Changelog = "https://github.com/spkane/freecad-mcp/blob/main/CHANGELOG.md"
|
||||
"Docker Hub" = "https://hub.docker.com/r/spkane/freecad-mcp"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/freecad_mcp"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 88
|
||||
src = ["src", "tests", "macros"]
|
||||
extend-include = ["*.FCMacro"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # Pyflakes
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"UP", # pyupgrade
|
||||
"ARG", # flake8-unused-arguments
|
||||
"SIM", # flake8-simplify
|
||||
"TCH", # flake8-type-checking
|
||||
"PTH", # flake8-use-pathlib
|
||||
"ERA", # eradicate (commented-out code)
|
||||
"PL", # Pylint
|
||||
"RUF", # Ruff-specific rules
|
||||
"S", # flake8-bandit (security)
|
||||
"ANN", # flake8-annotations
|
||||
"ASYNC", # flake8-async
|
||||
"D", # pydocstyle
|
||||
]
|
||||
ignore = [
|
||||
"D104", # Missing docstring in public package - __init__.py often just imports
|
||||
"D107", # Missing docstring in __init__ - class docstring covers this
|
||||
"ANN401", # Allow Any in type annotations - FreeCAD types are dynamic
|
||||
"ANN001", # Allow missing type annotations - FastMCP doesn't export types
|
||||
"PLR0913",# Too many arguments - CAD APIs have many params
|
||||
"PLR0915",# Too many statements - registration functions are long
|
||||
"PLC0415",# Allow imports in functions - FreeCAD must be imported lazily
|
||||
"PLR2004",# Allow magic numbers in comparisons - CAD dimensions
|
||||
"PLW0603",# Allow global statement - bridge lifecycle management
|
||||
"E501", # Line too long - embedded Python code strings
|
||||
"RUF022", # Don't sort __all__ - prefer logical grouping
|
||||
"S110", # Allow try-except-pass for optional operations
|
||||
"ARG002", # Allow unused arguments - needed for interface compliance
|
||||
]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**/*.py" = [
|
||||
"S101", # Allow asserts in tests
|
||||
"S108", # Allow /tmp in tests
|
||||
"ANN", # Don't require annotations in tests
|
||||
"D", # Don't require docstrings in tests
|
||||
"PLR2004",# Allow magic numbers in tests
|
||||
"SIM105", # Allow try-except-pass in tests
|
||||
"SIM117", # Allow nested with statements
|
||||
"B017", # Allow blind exception catch in tests
|
||||
]
|
||||
# FreeCAD macros have relaxed rules - they are standalone GUI scripts
|
||||
# But still require essential docstrings (module, class, public function)
|
||||
"**/*.FCMacro" = [
|
||||
"ANN", # Don't require type annotations - FreeCAD types are complex
|
||||
"D104", # Missing docstring in public package - N/A for macros
|
||||
"D105", # Missing docstring in magic method - __init__ etc don't need them
|
||||
"D107", # Missing docstring in __init__ - class docstring covers this
|
||||
"D203", # Blank line before class docstring (conflicts with D211)
|
||||
"D212", # Multi-line docstring start (conflicts with D213)
|
||||
"PLR0912",# Allow many branches - GUI code is complex
|
||||
"PLR0913",# Allow many arguments - CAD APIs have many params
|
||||
"PLR0915",# Allow many statements - macros are self-contained
|
||||
"SIM102", # Allow nested if statements - improves readability
|
||||
"SIM103", # Allow return after if-else for clarity
|
||||
"SIM108", # Allow if-else over ternary for readability
|
||||
"S112", # Allow try-except-continue - used for geometry iteration
|
||||
"F841", # Allow unused variables - Qt signal connections
|
||||
"UP008", # Allow super(Class, self) - PySide compatibility
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
# Not using strict mode due to FastMCP lacking type stubs
|
||||
warn_return_any = false
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = false
|
||||
disallow_incomplete_defs = false
|
||||
check_untyped_defs = true
|
||||
disallow_untyped_decorators = false
|
||||
no_implicit_optional = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = false
|
||||
warn_no_return = true
|
||||
follow_imports = "silent"
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "tests.*"
|
||||
disallow_untyped_defs = false
|
||||
disallow_incomplete_defs = false
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"freecad_mcp.tools",
|
||||
"freecad_mcp.tools.*",
|
||||
]
|
||||
disallow_untyped_decorators = false
|
||||
disallow_untyped_defs = false
|
||||
warn_return_any = false
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"freecad_mcp.resources",
|
||||
"freecad_mcp.resources.*",
|
||||
]
|
||||
disallow_untyped_decorators = false
|
||||
disallow_untyped_defs = false
|
||||
warn_return_any = false
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"freecad_mcp.prompts",
|
||||
"freecad_mcp.prompts.*",
|
||||
]
|
||||
disallow_untyped_decorators = false
|
||||
disallow_untyped_defs = false
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"freecad_mcp.bridge",
|
||||
"freecad_mcp.bridge.*",
|
||||
]
|
||||
warn_return_any = false
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"freecad_mcp.freecad_plugin",
|
||||
"freecad_mcp.freecad_plugin.*",
|
||||
]
|
||||
warn_return_any = false
|
||||
disallow_untyped_defs = false
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "freecad_mcp.server"
|
||||
disallow_untyped_decorators = false
|
||||
disallow_untyped_defs = false
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
addopts = [
|
||||
"-ra",
|
||||
"-q",
|
||||
"--strict-markers",
|
||||
"--strict-config",
|
||||
]
|
||||
markers = [
|
||||
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
||||
"integration: marks integration tests requiring FreeCAD",
|
||||
"gui: marks tests requiring FreeCAD GUI mode (not headless)",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["src/freecad_mcp"]
|
||||
branch = true
|
||||
parallel = true
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"raise AssertionError",
|
||||
"raise NotImplementedError",
|
||||
"if TYPE_CHECKING:",
|
||||
"if __name__ == .__main__.:",
|
||||
"@abstractmethod",
|
||||
]
|
||||
show_missing = true
|
||||
fail_under = 80
|
||||
|
||||
[tool.bandit]
|
||||
exclude_dirs = ["tests", "docs"]
|
||||
skips = [
|
||||
"B101", # Allow asserts
|
||||
"B102", # exec is required for FreeCAD Python execution
|
||||
"B110", # try-except-pass used for optional cleanup
|
||||
"B411", # XML-RPC is required for FreeCAD compatibility
|
||||
]
|
||||
|
||||
[tool.codespell]
|
||||
skip = ".git,*.lock,*.toml"
|
||||
ignore-words-list = "crate"
|
||||
@@ -0,0 +1,24 @@
|
||||
"""FreeCAD MCP Server - AI assistant integration for FreeCAD.
|
||||
|
||||
This package provides an MCP (Model Context Protocol) server that enables
|
||||
integration between AI assistants (Claude, GPT, etc.) and FreeCAD, allowing
|
||||
AI-assisted development and debugging of 3D models, macros, and workbenches.
|
||||
|
||||
Example:
|
||||
Run the MCP server::
|
||||
|
||||
$ freecad-mcp
|
||||
|
||||
Or with Python::
|
||||
|
||||
>>> from freecad_mcp.server import main
|
||||
>>> main()
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "Sean P. Kane"
|
||||
__email__ = "spkane@gmail.com"
|
||||
|
||||
from freecad_mcp.server import mcp
|
||||
|
||||
__all__ = ["__version__", "mcp"]
|
||||
@@ -0,0 +1,48 @@
|
||||
"""FreeCAD bridge implementations.
|
||||
|
||||
This package provides bridge implementations for communicating with FreeCAD
|
||||
in different modes (embedded, XML-RPC, and socket-based).
|
||||
|
||||
Bridge Modes:
|
||||
- EmbeddedBridge: In-process FreeCAD for headless operation (fastest)
|
||||
- XmlRpcBridge: XML-RPC protocol for GUI mode (neka-nat compatible)
|
||||
- SocketBridge: JSON-RPC over TCP for modern, lightweight communication
|
||||
"""
|
||||
|
||||
from freecad_mcp.bridge.base import (
|
||||
ConnectionStatus,
|
||||
DocumentInfo,
|
||||
ExecutionResult,
|
||||
FreecadBridge,
|
||||
MacroInfo,
|
||||
ObjectInfo,
|
||||
ObjectType,
|
||||
ScreenshotResult,
|
||||
ShapeInfo,
|
||||
ViewAngle,
|
||||
WorkbenchInfo,
|
||||
)
|
||||
from freecad_mcp.bridge.embedded import EmbeddedBridge
|
||||
from freecad_mcp.bridge.socket import JsonRpcError, SocketBridge
|
||||
from freecad_mcp.bridge.xmlrpc import XmlRpcBridge
|
||||
|
||||
__all__ = [
|
||||
# Base classes and types
|
||||
"ConnectionStatus",
|
||||
"DocumentInfo",
|
||||
"ExecutionResult",
|
||||
"FreecadBridge",
|
||||
"MacroInfo",
|
||||
"ObjectInfo",
|
||||
"ObjectType",
|
||||
"ScreenshotResult",
|
||||
"ShapeInfo",
|
||||
"ViewAngle",
|
||||
"WorkbenchInfo",
|
||||
# Bridge implementations
|
||||
"EmbeddedBridge",
|
||||
"XmlRpcBridge",
|
||||
"SocketBridge",
|
||||
# Exceptions
|
||||
"JsonRpcError",
|
||||
]
|
||||
@@ -0,0 +1,611 @@
|
||||
"""Abstract bridge interface for FreeCAD communication.
|
||||
|
||||
This module defines the abstract base class and data types for all FreeCAD
|
||||
bridge implementations. Bridges provide the communication layer between
|
||||
the MCP server and FreeCAD instances.
|
||||
|
||||
Based on learnings from existing implementations:
|
||||
- neka-nat: Queue-based thread safety for GUI operations
|
||||
- jango: Multiple connection modes with recovery
|
||||
- contextform: Comprehensive CAD operations
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ViewAngle(str, Enum):
|
||||
"""Standard view angles for screenshots."""
|
||||
|
||||
ISOMETRIC = "Isometric"
|
||||
FRONT = "Front"
|
||||
BACK = "Back"
|
||||
TOP = "Top"
|
||||
BOTTOM = "Bottom"
|
||||
LEFT = "Left"
|
||||
RIGHT = "Right"
|
||||
FIT_ALL = "FitAll"
|
||||
|
||||
|
||||
class ObjectType(str, Enum):
|
||||
"""FreeCAD object type categories."""
|
||||
|
||||
PART = "Part"
|
||||
PART_DESIGN = "PartDesign"
|
||||
DRAFT = "Draft"
|
||||
SKETCHER = "Sketcher"
|
||||
FEM = "Fem"
|
||||
MESH = "Mesh"
|
||||
SPREADSHEET = "Spreadsheet"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionResult:
|
||||
"""Result of Python code execution in FreeCAD.
|
||||
|
||||
Attributes:
|
||||
success: Whether execution completed without errors.
|
||||
result: The value assigned to `_result_` variable, or None.
|
||||
stdout: Captured standard output.
|
||||
stderr: Captured standard error.
|
||||
execution_time_ms: Time taken in milliseconds.
|
||||
error_type: Type of exception if failed, None otherwise.
|
||||
error_traceback: Full traceback if failed, None otherwise.
|
||||
"""
|
||||
|
||||
success: bool
|
||||
result: Any
|
||||
stdout: str
|
||||
stderr: str
|
||||
execution_time_ms: float
|
||||
error_type: str | None = None
|
||||
error_traceback: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocumentInfo:
|
||||
"""Information about a FreeCAD document.
|
||||
|
||||
Attributes:
|
||||
name: Internal document name (identifier).
|
||||
label: Display label (may differ from name).
|
||||
path: File path if saved, None otherwise.
|
||||
objects: List of object names in the document.
|
||||
is_modified: Whether document has unsaved changes.
|
||||
active_object: Name of the currently active object.
|
||||
"""
|
||||
|
||||
name: str
|
||||
label: str = ""
|
||||
path: str | None = None
|
||||
objects: list[str] = field(default_factory=list)
|
||||
is_modified: bool = False
|
||||
active_object: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Set label to name if not provided."""
|
||||
if not self.label:
|
||||
self.label = self.name
|
||||
|
||||
|
||||
@dataclass
|
||||
class ObjectInfo:
|
||||
"""Information about a FreeCAD object.
|
||||
|
||||
Attributes:
|
||||
name: Object name (identifier).
|
||||
label: Display label.
|
||||
type_id: FreeCAD TypeId string (e.g., "Part::Box").
|
||||
properties: Dictionary of property names to values.
|
||||
shape_info: Shape geometry details if applicable.
|
||||
children: List of child object names (OutList).
|
||||
parents: List of parent object names (InList).
|
||||
visibility: Whether object is visible in the view.
|
||||
"""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
type_id: str
|
||||
properties: dict[str, Any] = field(default_factory=dict)
|
||||
shape_info: dict[str, Any] | None = None
|
||||
children: list[str] = field(default_factory=list)
|
||||
parents: list[str] = field(default_factory=list)
|
||||
visibility: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShapeInfo:
|
||||
"""Detailed shape geometry information.
|
||||
|
||||
Attributes:
|
||||
shape_type: Type of shape (Solid, Shell, Face, etc.).
|
||||
volume: Volume of the shape (for solids).
|
||||
area: Surface area of the shape.
|
||||
center_of_mass: Center of mass coordinates.
|
||||
bounding_box: Bounding box as (min, max) tuples.
|
||||
is_valid: Whether the shape is geometrically valid.
|
||||
is_closed: Whether the shape is closed.
|
||||
vertex_count: Number of vertices.
|
||||
edge_count: Number of edges.
|
||||
face_count: Number of faces.
|
||||
"""
|
||||
|
||||
shape_type: str
|
||||
volume: float | None = None
|
||||
area: float | None = None
|
||||
center_of_mass: tuple[float, float, float] | None = None
|
||||
bounding_box: (
|
||||
tuple[tuple[float, float, float], tuple[float, float, float]] | None
|
||||
) = None
|
||||
is_valid: bool = True
|
||||
is_closed: bool = False
|
||||
vertex_count: int = 0
|
||||
edge_count: int = 0
|
||||
face_count: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScreenshotResult:
|
||||
"""Result of a screenshot capture.
|
||||
|
||||
Attributes:
|
||||
success: Whether screenshot was captured successfully.
|
||||
data: Base64-encoded image data.
|
||||
format: Image format (png, jpg).
|
||||
width: Image width in pixels.
|
||||
height: Image height in pixels.
|
||||
view_angle: The view angle used.
|
||||
error: Error message if failed.
|
||||
"""
|
||||
|
||||
success: bool
|
||||
data: str | None = None
|
||||
format: str = "png"
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
view_angle: ViewAngle | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MacroInfo:
|
||||
"""Information about a FreeCAD macro.
|
||||
|
||||
Attributes:
|
||||
name: Macro name (without extension).
|
||||
path: Full path to macro file.
|
||||
description: Macro description from comments.
|
||||
is_system: Whether it's a system macro.
|
||||
"""
|
||||
|
||||
name: str
|
||||
path: str
|
||||
description: str = ""
|
||||
is_system: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkbenchInfo:
|
||||
"""Information about a FreeCAD workbench.
|
||||
|
||||
Attributes:
|
||||
name: Workbench internal name.
|
||||
label: Display label.
|
||||
icon: Icon resource path.
|
||||
is_active: Whether workbench is currently active.
|
||||
"""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
icon: str = ""
|
||||
is_active: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionStatus:
|
||||
"""Status of the FreeCAD connection.
|
||||
|
||||
Attributes:
|
||||
connected: Whether connection is established.
|
||||
mode: Connection mode (embedded, xmlrpc, socket).
|
||||
freecad_version: FreeCAD version string.
|
||||
gui_available: Whether GUI is available.
|
||||
last_ping_ms: Last ping latency in milliseconds.
|
||||
error: Connection error message if any.
|
||||
"""
|
||||
|
||||
connected: bool
|
||||
mode: str
|
||||
freecad_version: str = ""
|
||||
gui_available: bool = False
|
||||
last_ping_ms: float = 0
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class FreecadBridge(ABC):
|
||||
"""Abstract base class for FreeCAD bridges.
|
||||
|
||||
A bridge provides communication between the MCP server and a FreeCAD
|
||||
instance. Implementations may run FreeCAD in-process (embedded),
|
||||
communicate via XML-RPC, or use JSON-RPC over sockets.
|
||||
|
||||
Thread Safety:
|
||||
GUI operations must be executed on the main thread. Implementations
|
||||
should use queue-based communication for thread safety (learned from
|
||||
neka-nat implementation).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def connect(self) -> None:
|
||||
"""Establish connection to FreeCAD.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If connection cannot be established.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def disconnect(self) -> None:
|
||||
"""Close connection to FreeCAD.
|
||||
|
||||
Should be called during cleanup to release resources.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def is_connected(self) -> bool:
|
||||
"""Check if bridge is connected to FreeCAD.
|
||||
|
||||
Returns:
|
||||
True if connected, False otherwise.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def ping(self) -> float:
|
||||
"""Ping FreeCAD to check connection and measure latency.
|
||||
|
||||
Returns:
|
||||
Round-trip time in milliseconds.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_status(self) -> ConnectionStatus:
|
||||
"""Get detailed connection status.
|
||||
|
||||
Returns:
|
||||
ConnectionStatus with full status information.
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# Code Execution
|
||||
# =========================================================================
|
||||
|
||||
@abstractmethod
|
||||
async def execute_python(
|
||||
self,
|
||||
code: str,
|
||||
timeout_ms: int = 30000,
|
||||
) -> ExecutionResult:
|
||||
"""Execute Python code in FreeCAD context.
|
||||
|
||||
The code runs with access to FreeCAD modules (FreeCAD, App, Gui).
|
||||
To return a value, assign it to the `_result_` variable.
|
||||
|
||||
Args:
|
||||
code: Python code to execute.
|
||||
timeout_ms: Maximum execution time in milliseconds.
|
||||
|
||||
Returns:
|
||||
ExecutionResult with success status, output, and any errors.
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# Document Management
|
||||
# =========================================================================
|
||||
|
||||
@abstractmethod
|
||||
async def get_documents(self) -> list[DocumentInfo]:
|
||||
"""Get list of open documents.
|
||||
|
||||
Returns:
|
||||
List of DocumentInfo for each open document.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_active_document(self) -> DocumentInfo | None:
|
||||
"""Get the active document.
|
||||
|
||||
Returns:
|
||||
DocumentInfo for active document, or None if no document is active.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def create_document(
|
||||
self, name: str, label: str | None = None
|
||||
) -> DocumentInfo:
|
||||
"""Create a new document.
|
||||
|
||||
Args:
|
||||
name: Internal document name (no spaces).
|
||||
label: Display label (optional, defaults to name).
|
||||
|
||||
Returns:
|
||||
DocumentInfo for the created document.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def open_document(self, path: str) -> DocumentInfo:
|
||||
"""Open an existing document.
|
||||
|
||||
Args:
|
||||
path: Path to the .FCStd file.
|
||||
|
||||
Returns:
|
||||
DocumentInfo for the opened document.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If file doesn't exist.
|
||||
ValueError: If file is not a valid FreeCAD document.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def save_document(
|
||||
self,
|
||||
doc_name: str | None = None,
|
||||
path: str | None = None,
|
||||
) -> str:
|
||||
"""Save a document.
|
||||
|
||||
Args:
|
||||
doc_name: Document name (uses active if None).
|
||||
path: Save path (uses existing path if None).
|
||||
|
||||
Returns:
|
||||
Path where document was saved.
|
||||
|
||||
Raises:
|
||||
ValueError: If document not found or no path specified for new doc.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def close_document(self, doc_name: str | None = None) -> None:
|
||||
"""Close a document.
|
||||
|
||||
Args:
|
||||
doc_name: Document name (uses active if None).
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# Object Management
|
||||
# =========================================================================
|
||||
|
||||
@abstractmethod
|
||||
async def get_objects(self, doc_name: str | None = None) -> list[ObjectInfo]:
|
||||
"""Get all objects in a document.
|
||||
|
||||
Args:
|
||||
doc_name: Document name (uses active if None).
|
||||
|
||||
Returns:
|
||||
List of ObjectInfo for each object.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_object(
|
||||
self,
|
||||
obj_name: str,
|
||||
doc_name: str | None = None,
|
||||
) -> ObjectInfo:
|
||||
"""Get detailed object information.
|
||||
|
||||
Args:
|
||||
obj_name: Name of the object.
|
||||
doc_name: Document name (uses active if None).
|
||||
|
||||
Returns:
|
||||
ObjectInfo with full object details.
|
||||
|
||||
Raises:
|
||||
ValueError: If object not found.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def create_object(
|
||||
self,
|
||||
type_id: str,
|
||||
name: str | None = None,
|
||||
properties: dict[str, Any] | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> ObjectInfo:
|
||||
"""Create a new object.
|
||||
|
||||
Args:
|
||||
type_id: FreeCAD type ID (e.g., "Part::Box", "Part::Cylinder").
|
||||
name: Object name (auto-generated if None).
|
||||
properties: Initial property values.
|
||||
doc_name: Target document (uses active if None).
|
||||
|
||||
Returns:
|
||||
ObjectInfo for the created object.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def edit_object(
|
||||
self,
|
||||
obj_name: str,
|
||||
properties: dict[str, Any],
|
||||
doc_name: str | None = None,
|
||||
) -> ObjectInfo:
|
||||
"""Edit object properties.
|
||||
|
||||
Args:
|
||||
obj_name: Name of the object to edit.
|
||||
properties: Property values to set.
|
||||
doc_name: Document name (uses active if None).
|
||||
|
||||
Returns:
|
||||
Updated ObjectInfo.
|
||||
|
||||
Raises:
|
||||
ValueError: If object not found.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def delete_object(
|
||||
self,
|
||||
obj_name: str,
|
||||
doc_name: str | None = None,
|
||||
) -> None:
|
||||
"""Delete an object.
|
||||
|
||||
Args:
|
||||
obj_name: Name of the object to delete.
|
||||
doc_name: Document name (uses active if None).
|
||||
|
||||
Raises:
|
||||
ValueError: If object not found.
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# View and Screenshot
|
||||
# =========================================================================
|
||||
|
||||
@abstractmethod
|
||||
async def get_screenshot(
|
||||
self,
|
||||
view_angle: ViewAngle | None = None,
|
||||
width: int = 800,
|
||||
height: int = 600,
|
||||
doc_name: str | None = None,
|
||||
) -> ScreenshotResult:
|
||||
"""Capture a screenshot of the 3D view.
|
||||
|
||||
Args:
|
||||
view_angle: View angle to set before capture.
|
||||
width: Image width in pixels.
|
||||
height: Image height in pixels.
|
||||
doc_name: Document name (uses active if None).
|
||||
|
||||
Returns:
|
||||
ScreenshotResult with image data or error.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def set_view(
|
||||
self,
|
||||
view_angle: ViewAngle,
|
||||
doc_name: str | None = None,
|
||||
) -> None:
|
||||
"""Set the 3D view angle.
|
||||
|
||||
Args:
|
||||
view_angle: View angle to set.
|
||||
doc_name: Document name (uses active if None).
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# Macros
|
||||
# =========================================================================
|
||||
|
||||
@abstractmethod
|
||||
async def get_macros(self) -> list[MacroInfo]:
|
||||
"""Get list of available macros.
|
||||
|
||||
Returns:
|
||||
List of MacroInfo for each macro.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def run_macro(
|
||||
self,
|
||||
macro_name: str,
|
||||
args: dict[str, Any] | None = None,
|
||||
) -> ExecutionResult:
|
||||
"""Run a macro by name.
|
||||
|
||||
Args:
|
||||
macro_name: Macro name (without .FCMacro extension).
|
||||
args: Arguments to pass to the macro.
|
||||
|
||||
Returns:
|
||||
ExecutionResult from macro execution.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def create_macro(
|
||||
self,
|
||||
name: str,
|
||||
code: str,
|
||||
description: str = "",
|
||||
) -> MacroInfo:
|
||||
"""Create a new macro.
|
||||
|
||||
Args:
|
||||
name: Macro name (without extension).
|
||||
code: Python code for the macro.
|
||||
description: Macro description.
|
||||
|
||||
Returns:
|
||||
MacroInfo for the created macro.
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# Workbenches
|
||||
# =========================================================================
|
||||
|
||||
@abstractmethod
|
||||
async def get_workbenches(self) -> list[WorkbenchInfo]:
|
||||
"""Get list of available workbenches.
|
||||
|
||||
Returns:
|
||||
List of WorkbenchInfo for each workbench.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def activate_workbench(self, workbench_name: str) -> None:
|
||||
"""Activate a workbench.
|
||||
|
||||
Args:
|
||||
workbench_name: Workbench internal name.
|
||||
|
||||
Raises:
|
||||
ValueError: If workbench not found.
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# Version and Environment
|
||||
# =========================================================================
|
||||
|
||||
@abstractmethod
|
||||
async def get_freecad_version(self) -> dict[str, Any]:
|
||||
"""Get FreeCAD version information.
|
||||
|
||||
Returns:
|
||||
Dictionary with version, build_date, python_version, gui_available.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def is_gui_available(self) -> bool:
|
||||
"""Check if FreeCAD GUI is available.
|
||||
|
||||
Returns:
|
||||
True if GUI is available, False for headless mode.
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# Console
|
||||
# =========================================================================
|
||||
|
||||
@abstractmethod
|
||||
async def get_console_output(self, lines: int = 100) -> list[str]:
|
||||
"""Get recent console output.
|
||||
|
||||
Args:
|
||||
lines: Maximum number of lines to return.
|
||||
|
||||
Returns:
|
||||
List of console output lines, most recent last.
|
||||
"""
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
||||
"""Configuration management for FreeCAD MCP Server.
|
||||
|
||||
This module handles all configuration settings for the MCP server,
|
||||
including FreeCAD connection settings, execution limits, and logging.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class FreecadMode(str, Enum):
|
||||
"""FreeCAD connection mode."""
|
||||
|
||||
EMBEDDED = "embedded"
|
||||
SOCKET = "socket"
|
||||
XMLRPC = "xmlrpc"
|
||||
|
||||
|
||||
class TransportType(str, Enum):
|
||||
"""MCP transport type."""
|
||||
|
||||
STDIO = "stdio"
|
||||
HTTP = "http"
|
||||
|
||||
|
||||
class ServerConfig(BaseSettings):
|
||||
"""Configuration for the FreeCAD MCP server.
|
||||
|
||||
Settings are loaded from environment variables with the FREECAD_ prefix.
|
||||
For example, FREECAD_MODE sets the mode field.
|
||||
|
||||
Attributes:
|
||||
mode: Connection mode - 'embedded', 'socket', or 'xmlrpc'.
|
||||
freecad_path: Path to FreeCAD's lib directory (for embedded mode).
|
||||
socket_host: Hostname for socket/xmlrpc connection.
|
||||
socket_port: Port for JSON-RPC socket connection (default 9876).
|
||||
xmlrpc_port: Port for XML-RPC connection (default 9875, neka-nat compatible).
|
||||
timeout_ms: Default execution timeout in milliseconds.
|
||||
max_output_size: Maximum output size in bytes.
|
||||
transport: MCP transport type.
|
||||
http_port: Port for HTTP transport.
|
||||
log_level: Logging level.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="FREECAD_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# FreeCAD connection settings
|
||||
mode: FreecadMode = FreecadMode.EMBEDDED
|
||||
freecad_path: Annotated[
|
||||
Path | None,
|
||||
Field(
|
||||
description="Path to FreeCAD's lib directory",
|
||||
alias="FREECAD_PATH",
|
||||
),
|
||||
] = None
|
||||
|
||||
# Socket settings (for socket mode)
|
||||
socket_host: Annotated[
|
||||
str,
|
||||
Field(description="Socket/XML-RPC server hostname"),
|
||||
] = "localhost"
|
||||
socket_port: Annotated[
|
||||
int,
|
||||
Field(ge=1, le=65535, description="Socket server port (JSON-RPC)"),
|
||||
] = 9876
|
||||
xmlrpc_port: Annotated[
|
||||
int,
|
||||
Field(ge=1, le=65535, description="XML-RPC server port (neka-nat compatible)"),
|
||||
] = 9875
|
||||
|
||||
# Execution limits
|
||||
timeout_ms: Annotated[
|
||||
int,
|
||||
Field(ge=1000, le=600000, description="Execution timeout in ms"),
|
||||
] = 30000
|
||||
max_output_size: Annotated[
|
||||
int,
|
||||
Field(ge=1000, description="Maximum output size in bytes"),
|
||||
] = 1_000_000
|
||||
|
||||
# MCP transport settings
|
||||
transport: TransportType = TransportType.STDIO
|
||||
http_port: Annotated[
|
||||
int,
|
||||
Field(ge=1, le=65535, description="HTTP server port"),
|
||||
] = 8000
|
||||
|
||||
# Logging
|
||||
log_level: str = "INFO"
|
||||
|
||||
# Security settings
|
||||
enable_sandbox: bool = True
|
||||
allow_file_access: bool = True
|
||||
allow_network_access: bool = False
|
||||
|
||||
|
||||
def get_config() -> ServerConfig:
|
||||
"""Get the server configuration.
|
||||
|
||||
Returns:
|
||||
ServerConfig instance populated from environment variables.
|
||||
"""
|
||||
return ServerConfig()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""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
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
#!/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()
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Headless FreeCAD MCP Bridge Server.
|
||||
|
||||
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
|
||||
|
||||
Note: In headless mode, GUI features like screenshots are not available.
|
||||
For full functionality, use the StartMCPBridge macro in FreeCAD's GUI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Check if we're running inside FreeCAD
|
||||
try:
|
||||
import FreeCAD
|
||||
|
||||
print(f"FreeCAD version: {FreeCAD.Version()[0]}.{FreeCAD.Version()[1]}")
|
||||
except ImportError:
|
||||
print("ERROR: This script must be run with FreeCADCmd or inside FreeCAD.")
|
||||
print("")
|
||||
print("Usage:")
|
||||
print(" 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"
|
||||
)
|
||||
print("")
|
||||
print("On Linux:")
|
||||
print(" freecadcmd 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
|
||||
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
|
||||
|
||||
# Create and run the plugin
|
||||
plugin = FreecadMCPPlugin(
|
||||
host="localhost",
|
||||
port=9876, # JSON-RPC socket port
|
||||
xmlrpc_port=9875, # XML-RPC port (neka-nat compatible)
|
||||
enable_xmlrpc=True,
|
||||
)
|
||||
|
||||
# Start the plugin
|
||||
plugin.start()
|
||||
|
||||
# Print status messages with flush to ensure they appear immediately
|
||||
# (FreeCAD's Python may have buffered stdout)
|
||||
print("", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
print("MCP Bridge started in headless mode!", flush=True)
|
||||
print(" - XML-RPC: localhost:9875", flush=True)
|
||||
print(" - Socket: localhost:9876", flush=True)
|
||||
print("", flush=True)
|
||||
print(
|
||||
"Note: Screenshot and view features are not available in headless mode.", flush=True
|
||||
)
|
||||
print("Press Ctrl+C to stop.", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
print("", flush=True)
|
||||
|
||||
# Run forever (blocks until Ctrl+C)
|
||||
plugin.run_forever()
|
||||
@@ -0,0 +1,708 @@
|
||||
"""FreeCAD MCP Bridge Plugin - Socket Server with Queue-based Thread Safety.
|
||||
|
||||
This module provides a socket server that runs inside FreeCAD to handle
|
||||
MCP bridge requests. It must be executed within FreeCAD's Python environment.
|
||||
|
||||
Design inspired by neka-nat/freecad-mcp (MIT License):
|
||||
- Queue-based GUI communication for thread safety
|
||||
- XML-RPC compatibility mode (port 9875)
|
||||
- Screenshot capture with view type detection
|
||||
|
||||
Attribution:
|
||||
The queue-based thread safety pattern and XML-RPC protocol design were
|
||||
inspired by neka-nat/freecad-mcp (https://github.com/neka-nat/freecad-mcp),
|
||||
which is licensed under the MIT License. This implementation is a complete
|
||||
rewrite with additional features (JSON-RPC 2.0, async socket server).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import xmlrpc.server
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from typing import Any
|
||||
|
||||
# These imports only work inside FreeCAD
|
||||
try:
|
||||
import FreeCAD
|
||||
import FreeCADGui
|
||||
|
||||
FREECAD_AVAILABLE = True
|
||||
except ImportError:
|
||||
FREECAD_AVAILABLE = False
|
||||
|
||||
# Default configuration
|
||||
DEFAULT_SOCKET_PORT = 9876
|
||||
DEFAULT_XMLRPC_PORT = 9875
|
||||
QUEUE_POLL_INTERVAL_MS = 50
|
||||
STATUS_UPDATE_INTERVAL_MS = 5000 # Update status bar every 5 seconds
|
||||
|
||||
|
||||
class ExecutionRequest:
|
||||
"""Represents a code execution request."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
timeout_ms: int = 30000,
|
||||
request_id: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize execution request.
|
||||
|
||||
Args:
|
||||
code: Python code to execute.
|
||||
timeout_ms: Execution timeout in milliseconds.
|
||||
request_id: Optional request ID for tracking.
|
||||
"""
|
||||
self.code = code
|
||||
self.timeout_ms = timeout_ms
|
||||
self.request_id = request_id
|
||||
self.result: dict[str, Any] | None = None
|
||||
self.completed = threading.Event()
|
||||
|
||||
|
||||
class FreecadMCPPlugin:
|
||||
"""Plugin that runs inside FreeCAD to handle MCP bridge requests.
|
||||
|
||||
This class creates servers that accept connections from the MCP server
|
||||
and executes commands in FreeCAD's context using a thread-safe queue
|
||||
system for GUI operations.
|
||||
|
||||
Attributes:
|
||||
socket_host: Hostname for socket server.
|
||||
socket_port: Port for JSON-RPC socket server.
|
||||
xmlrpc_port: Port for XML-RPC server.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "localhost",
|
||||
port: int = DEFAULT_SOCKET_PORT,
|
||||
xmlrpc_port: int = DEFAULT_XMLRPC_PORT,
|
||||
enable_xmlrpc: bool = True,
|
||||
) -> None:
|
||||
"""Initialize the plugin.
|
||||
|
||||
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.
|
||||
"""
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._xmlrpc_port = xmlrpc_port
|
||||
self._enable_xmlrpc = enable_xmlrpc
|
||||
|
||||
# Server instances
|
||||
self._socket_server: asyncio.Server | None = None
|
||||
self._xmlrpc_server: xmlrpc.server.SimpleXMLRPCServer | None = None
|
||||
self._socket_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
# Threading
|
||||
self._socket_thread: threading.Thread | None = None
|
||||
self._xmlrpc_thread: threading.Thread | None = None
|
||||
self._running = False
|
||||
|
||||
# Queue-based execution for thread safety (learned from neka-nat)
|
||||
self._request_queue: queue.Queue[ExecutionRequest] = queue.Queue()
|
||||
self._timer = None
|
||||
self._queue_thread: threading.Thread | None = None
|
||||
self._headless = False
|
||||
|
||||
# Status bar tracking
|
||||
self._status_timer = None
|
||||
self._request_count = 0
|
||||
self._last_request_time: float | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start all servers."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
|
||||
# Start the queue processing timer on the main thread
|
||||
self._start_queue_processor()
|
||||
|
||||
# Start socket server
|
||||
self._socket_thread = threading.Thread(
|
||||
target=self._run_socket_server,
|
||||
daemon=True,
|
||||
name="MCP-Socket",
|
||||
)
|
||||
self._socket_thread.start()
|
||||
|
||||
# Start XML-RPC server if enabled
|
||||
if self._enable_xmlrpc:
|
||||
self._xmlrpc_thread = threading.Thread(
|
||||
target=self._run_xmlrpc_server,
|
||||
daemon=True,
|
||||
name="MCP-XMLRPC",
|
||||
)
|
||||
self._xmlrpc_thread.start()
|
||||
|
||||
if FREECAD_AVAILABLE:
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"MCP Bridge started:\n - JSON-RPC: {self._host}:{self._port}\n"
|
||||
)
|
||||
if self._enable_xmlrpc:
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f" - XML-RPC: {self._host}:{self._xmlrpc_port}\n"
|
||||
)
|
||||
|
||||
# Start status bar updates in GUI mode
|
||||
self._start_status_updates()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop all servers."""
|
||||
self._running = False
|
||||
|
||||
# Stop status bar updates
|
||||
self._stop_status_updates()
|
||||
|
||||
# Stop queue processor timer (GUI mode)
|
||||
if self._timer:
|
||||
with contextlib.suppress(Exception):
|
||||
self._timer.stop()
|
||||
self._timer = None
|
||||
|
||||
# Stop queue processor thread (headless mode)
|
||||
if self._queue_thread:
|
||||
self._queue_thread.join(timeout=2.0)
|
||||
self._queue_thread = None
|
||||
|
||||
# Stop socket server
|
||||
if self._socket_loop and self._socket_server:
|
||||
self._socket_loop.call_soon_threadsafe(self._socket_server.close)
|
||||
|
||||
# Stop XML-RPC server
|
||||
if self._xmlrpc_server:
|
||||
self._xmlrpc_server.shutdown()
|
||||
|
||||
# Wait for threads
|
||||
if self._socket_thread:
|
||||
self._socket_thread.join(timeout=5.0)
|
||||
self._socket_thread = None
|
||||
|
||||
if self._xmlrpc_thread:
|
||||
self._xmlrpc_thread.join(timeout=5.0)
|
||||
self._xmlrpc_thread = None
|
||||
|
||||
if FREECAD_AVAILABLE:
|
||||
FreeCAD.Console.PrintMessage("MCP Bridge stopped\n")
|
||||
|
||||
def run_forever(self) -> None:
|
||||
"""Run the server indefinitely (for headless mode).
|
||||
|
||||
This method blocks until interrupted (Ctrl+C) or stop() is called.
|
||||
Use this when running FreeCAD in headless/console mode.
|
||||
"""
|
||||
self.start()
|
||||
if FREECAD_AVAILABLE:
|
||||
FreeCAD.Console.PrintMessage("Server running. Press Ctrl+C to stop.\n")
|
||||
try:
|
||||
while self._running:
|
||||
time.sleep(0.5)
|
||||
except KeyboardInterrupt:
|
||||
if FREECAD_AVAILABLE:
|
||||
FreeCAD.Console.PrintMessage("\nShutting down...\n")
|
||||
finally:
|
||||
self.stop()
|
||||
|
||||
# =========================================================================
|
||||
# Status Bar Updates (GUI mode only)
|
||||
# =========================================================================
|
||||
|
||||
def _start_status_updates(self) -> None:
|
||||
"""Start periodic status bar updates in GUI mode."""
|
||||
if not (FREECAD_AVAILABLE and FreeCAD.GuiUp):
|
||||
return
|
||||
|
||||
# Try to import Qt - need to check both PySide2 and PySide6
|
||||
QtCore = None
|
||||
with contextlib.suppress(ImportError):
|
||||
from PySide2 import QtCore # type: ignore[no-redef]
|
||||
if QtCore is None:
|
||||
with contextlib.suppress(ImportError):
|
||||
from PySide6 import QtCore # type: ignore[no-redef]
|
||||
|
||||
if QtCore is None:
|
||||
return
|
||||
|
||||
# Create timer for status updates
|
||||
timer = QtCore.QTimer()
|
||||
timer.timeout.connect(self._update_status_bar)
|
||||
timer.start(STATUS_UPDATE_INTERVAL_MS)
|
||||
self._status_timer = timer
|
||||
|
||||
# Show initial status
|
||||
self._update_status_bar()
|
||||
|
||||
def _stop_status_updates(self) -> None:
|
||||
"""Stop status bar updates and clear the status."""
|
||||
if self._status_timer:
|
||||
with contextlib.suppress(Exception):
|
||||
self._status_timer.stop()
|
||||
self._status_timer = None
|
||||
|
||||
# Clear status bar message
|
||||
if FREECAD_AVAILABLE and FreeCAD.GuiUp:
|
||||
self._set_status_bar("")
|
||||
|
||||
def _update_status_bar(self) -> None:
|
||||
"""Update the FreeCAD status bar with MCP bridge status."""
|
||||
if not (FREECAD_AVAILABLE and FreeCAD.GuiUp):
|
||||
return
|
||||
|
||||
# Build status message
|
||||
ports = f"XML-RPC:{self._xmlrpc_port}" if self._enable_xmlrpc else ""
|
||||
if ports:
|
||||
ports = f" ({ports})"
|
||||
|
||||
if self._request_count > 0:
|
||||
# Show activity info
|
||||
if self._last_request_time:
|
||||
elapsed = time.time() - self._last_request_time
|
||||
if elapsed < 60:
|
||||
time_ago = f"{int(elapsed)}s ago"
|
||||
else:
|
||||
time_ago = f"{int(elapsed / 60)}m ago"
|
||||
status = f"🔌 MCP Bridge active{ports} | {self._request_count} requests | last: {time_ago}"
|
||||
else:
|
||||
status = f"🔌 MCP Bridge active{ports} | {self._request_count} requests"
|
||||
else:
|
||||
status = f"🔌 MCP Bridge running{ports} | waiting for connections..."
|
||||
|
||||
self._set_status_bar(status)
|
||||
|
||||
def _set_status_bar(self, message: str) -> None:
|
||||
"""Set the FreeCAD main window status bar message.
|
||||
|
||||
Args:
|
||||
message: Message to display in status bar.
|
||||
"""
|
||||
if not (FREECAD_AVAILABLE and FreeCAD.GuiUp):
|
||||
return
|
||||
|
||||
try:
|
||||
main_window = FreeCADGui.getMainWindow()
|
||||
if main_window:
|
||||
status_bar = main_window.statusBar()
|
||||
if status_bar:
|
||||
if message:
|
||||
# Show message persistently (0 = no timeout)
|
||||
status_bar.showMessage(message, 0)
|
||||
else:
|
||||
status_bar.clearMessage()
|
||||
except Exception:
|
||||
# Silently ignore status bar errors
|
||||
pass
|
||||
|
||||
def _record_request(self) -> None:
|
||||
"""Record that a request was processed (for status tracking)."""
|
||||
self._request_count += 1
|
||||
self._last_request_time = time.time()
|
||||
|
||||
# =========================================================================
|
||||
# Queue-based Thread Safety (from neka-nat)
|
||||
# =========================================================================
|
||||
|
||||
def _start_queue_processor(self) -> None:
|
||||
"""Start the queue processor on the main GUI thread or as background thread."""
|
||||
# Check if we're in GUI mode using FreeCAD.GuiUp
|
||||
# Note: Qt (PySide) may be available even in headless mode, but without
|
||||
# a running event loop, Qt timers won't fire. Use GuiUp to detect this.
|
||||
gui_available = FREECAD_AVAILABLE and FreeCAD.GuiUp
|
||||
|
||||
if gui_available:
|
||||
# GUI mode: use Qt timer for thread-safe GUI operations
|
||||
try:
|
||||
from PySide2 import QtCore
|
||||
except ImportError:
|
||||
try:
|
||||
from PySide6 import QtCore
|
||||
except ImportError:
|
||||
QtCore = None # type: ignore[assignment]
|
||||
|
||||
if QtCore is not None:
|
||||
timer = QtCore.QTimer()
|
||||
timer.timeout.connect(self._process_queue)
|
||||
timer.start(QUEUE_POLL_INTERVAL_MS)
|
||||
self._timer = timer
|
||||
return
|
||||
|
||||
# Headless mode: use a background thread for queue processing
|
||||
# In headless mode, there's no GUI thread concern, so direct
|
||||
# processing in a background thread is safe
|
||||
self._headless = True
|
||||
self._queue_thread = threading.Thread(
|
||||
target=self._run_queue_processor_loop,
|
||||
daemon=True,
|
||||
name="MCP-QueueProcessor",
|
||||
)
|
||||
self._queue_thread.start()
|
||||
if FREECAD_AVAILABLE:
|
||||
FreeCAD.Console.PrintMessage(
|
||||
"Running in headless mode (queue processor thread started)\n"
|
||||
)
|
||||
|
||||
def _run_queue_processor_loop(self) -> None:
|
||||
"""Run queue processor in a loop for headless mode."""
|
||||
while self._running:
|
||||
self._process_queue()
|
||||
time.sleep(QUEUE_POLL_INTERVAL_MS / 1000.0)
|
||||
|
||||
def _process_queue(self) -> None:
|
||||
"""Process pending execution requests on the main thread.
|
||||
|
||||
This method is called periodically by a Qt timer to ensure
|
||||
GUI operations happen on the main thread.
|
||||
"""
|
||||
while not self._request_queue.empty():
|
||||
try:
|
||||
request = self._request_queue.get_nowait()
|
||||
result = self._execute_code_sync(request.code)
|
||||
request.result = result
|
||||
request.completed.set()
|
||||
# Track request for status bar
|
||||
self._record_request()
|
||||
except queue.Empty:
|
||||
break
|
||||
except Exception as e:
|
||||
if FREECAD_AVAILABLE:
|
||||
FreeCAD.Console.PrintError(f"Queue processing error: {e}\n")
|
||||
|
||||
def _execute_via_queue(
|
||||
self,
|
||||
code: str,
|
||||
timeout_ms: int = 30000,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute code via the queue system for thread safety.
|
||||
|
||||
Args:
|
||||
code: Python code to execute.
|
||||
timeout_ms: Execution timeout in milliseconds.
|
||||
|
||||
Returns:
|
||||
Execution result dictionary.
|
||||
"""
|
||||
request = ExecutionRequest(code, timeout_ms)
|
||||
self._request_queue.put(request)
|
||||
|
||||
# Wait for completion
|
||||
if request.completed.wait(timeout=timeout_ms / 1000):
|
||||
return request.result or {
|
||||
"success": False,
|
||||
"error_type": "InternalError",
|
||||
"error_message": "No result returned",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error_type": "TimeoutError",
|
||||
"error_message": f"Execution timed out after {timeout_ms}ms",
|
||||
"execution_time_ms": timeout_ms,
|
||||
}
|
||||
|
||||
def _execute_code_sync(self, code: str) -> dict[str, Any]:
|
||||
"""Execute Python code synchronously (call on main thread only).
|
||||
|
||||
Args:
|
||||
code: Python code to execute.
|
||||
|
||||
Returns:
|
||||
Execution result dictionary.
|
||||
"""
|
||||
start = time.perf_counter()
|
||||
stdout_capture = io.StringIO()
|
||||
stderr_capture = io.StringIO()
|
||||
|
||||
exec_globals: dict[str, Any] = {
|
||||
"__builtins__": __builtins__,
|
||||
}
|
||||
|
||||
if FREECAD_AVAILABLE:
|
||||
exec_globals["FreeCAD"] = FreeCAD
|
||||
exec_globals["App"] = FreeCAD
|
||||
exec_globals["FreeCADGui"] = FreeCADGui
|
||||
exec_globals["Gui"] = FreeCADGui
|
||||
|
||||
try:
|
||||
with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
|
||||
compiled = compile(code, "<mcp>", "exec")
|
||||
exec(compiled, exec_globals) # noqa: S102
|
||||
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
return {
|
||||
"success": True,
|
||||
"result": exec_globals.get("_result_"),
|
||||
"stdout": stdout_capture.getvalue(),
|
||||
"stderr": stderr_capture.getvalue(),
|
||||
"execution_time_ms": elapsed,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
return {
|
||||
"success": False,
|
||||
"result": None,
|
||||
"stdout": stdout_capture.getvalue(),
|
||||
"stderr": stderr_capture.getvalue(),
|
||||
"execution_time_ms": elapsed,
|
||||
"error_type": type(e).__name__,
|
||||
"error_message": str(e),
|
||||
"error_traceback": traceback.format_exc(),
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# Socket Server (JSON-RPC 2.0)
|
||||
# =========================================================================
|
||||
|
||||
def _run_socket_server(self) -> None:
|
||||
"""Run the asyncio event loop in background thread."""
|
||||
self._socket_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._socket_loop)
|
||||
|
||||
try:
|
||||
self._socket_loop.run_until_complete(self._start_socket_server())
|
||||
self._socket_loop.run_forever()
|
||||
finally:
|
||||
self._socket_loop.close()
|
||||
|
||||
async def _start_socket_server(self) -> None:
|
||||
"""Start the TCP server."""
|
||||
self._socket_server = await asyncio.start_server(
|
||||
self._handle_socket_client,
|
||||
self._host,
|
||||
self._port,
|
||||
)
|
||||
|
||||
async def _handle_socket_client(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
"""Handle a connected socket client.
|
||||
|
||||
Args:
|
||||
reader: Stream reader for incoming data.
|
||||
writer: Stream writer for outgoing data.
|
||||
"""
|
||||
peer = writer.get_extra_info("peername")
|
||||
if FREECAD_AVAILABLE:
|
||||
FreeCAD.Console.PrintMessage(f"MCP client connected (socket): {peer}\n")
|
||||
|
||||
try:
|
||||
while self._running:
|
||||
data = await reader.readline()
|
||||
if not data:
|
||||
break
|
||||
|
||||
try:
|
||||
request = json.loads(data.decode("utf-8"))
|
||||
response = await self._process_jsonrpc_request(request)
|
||||
except json.JSONDecodeError as e:
|
||||
response = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {
|
||||
"code": -32700,
|
||||
"message": "Parse error",
|
||||
"data": str(e),
|
||||
},
|
||||
}
|
||||
|
||||
response_data = json.dumps(response).encode("utf-8") + b"\n"
|
||||
writer.write(response_data)
|
||||
await writer.drain()
|
||||
|
||||
except Exception as e:
|
||||
if FREECAD_AVAILABLE:
|
||||
FreeCAD.Console.PrintError(f"MCP socket error: {e}\n")
|
||||
finally:
|
||||
if FREECAD_AVAILABLE:
|
||||
FreeCAD.Console.PrintMessage(f"MCP client disconnected: {peer}\n")
|
||||
writer.close()
|
||||
with contextlib.suppress(Exception):
|
||||
await writer.wait_closed()
|
||||
|
||||
async def _process_jsonrpc_request(
|
||||
self,
|
||||
request: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Process a JSON-RPC 2.0 request.
|
||||
|
||||
Args:
|
||||
request: JSON-RPC request dictionary.
|
||||
|
||||
Returns:
|
||||
JSON-RPC response dictionary.
|
||||
"""
|
||||
request_id = request.get("id")
|
||||
method = request.get("method")
|
||||
params = request.get("params", {})
|
||||
|
||||
# Handle ping specially (no queue needed)
|
||||
if method == "ping":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {"pong": True, "timestamp": time.time()},
|
||||
}
|
||||
|
||||
# Handle execute via queue
|
||||
if method == "execute":
|
||||
code = params.get("code", "")
|
||||
timeout_ms = params.get("timeout_ms", 30000)
|
||||
|
||||
# Execute via queue for thread safety
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._execute_via_queue(code, timeout_ms),
|
||||
)
|
||||
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": result,
|
||||
}
|
||||
|
||||
# Unknown method
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": "Method not found",
|
||||
"data": f"Unknown method: {method}",
|
||||
},
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# XML-RPC Server (neka-nat compatible)
|
||||
# =========================================================================
|
||||
|
||||
def _run_xmlrpc_server(self) -> None:
|
||||
"""Run the XML-RPC server."""
|
||||
self._xmlrpc_server = xmlrpc.server.SimpleXMLRPCServer(
|
||||
(self._host, self._xmlrpc_port),
|
||||
allow_none=True,
|
||||
logRequests=False,
|
||||
)
|
||||
|
||||
# Register methods (type: ignore needed - xmlrpc types are overly restrictive)
|
||||
self._xmlrpc_server.register_function(self._xmlrpc_execute, "execute") # type: ignore[arg-type]
|
||||
self._xmlrpc_server.register_function(self._xmlrpc_ping, "ping") # type: ignore[arg-type]
|
||||
self._xmlrpc_server.register_function(self._xmlrpc_get_view, "get_view") # type: ignore[arg-type]
|
||||
self._xmlrpc_server.register_introspection_functions()
|
||||
|
||||
while self._running:
|
||||
self._xmlrpc_server.handle_request()
|
||||
|
||||
def _xmlrpc_ping(self) -> dict[str, Any]:
|
||||
"""XML-RPC ping handler."""
|
||||
return {"pong": True, "timestamp": time.time()}
|
||||
|
||||
def _xmlrpc_execute(self, code: str) -> dict[str, Any]:
|
||||
"""XML-RPC execute handler (neka-nat compatible).
|
||||
|
||||
Args:
|
||||
code: Python code to execute.
|
||||
|
||||
Returns:
|
||||
Execution result dictionary.
|
||||
"""
|
||||
return self._execute_via_queue(code, 30000)
|
||||
|
||||
def _xmlrpc_get_view(
|
||||
self,
|
||||
width: int = 800,
|
||||
height: int = 600,
|
||||
view_type: str = "Isometric",
|
||||
) -> dict[str, Any]:
|
||||
"""XML-RPC get_view handler for screenshots (neka-nat compatible).
|
||||
|
||||
Args:
|
||||
width: Image width.
|
||||
height: Image height.
|
||||
view_type: View angle type.
|
||||
|
||||
Returns:
|
||||
Dictionary with base64 image data or error.
|
||||
"""
|
||||
code = f"""
|
||||
import base64
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
if not FreeCAD.GuiUp:
|
||||
_result_ = {{"success": False, "error": "GUI not available"}}
|
||||
else:
|
||||
doc = FreeCAD.ActiveDocument
|
||||
if doc is None:
|
||||
_result_ = {{"success": False, "error": "No active document"}}
|
||||
else:
|
||||
view = FreeCADGui.ActiveDocument.ActiveView
|
||||
if view is None:
|
||||
_result_ = {{"success": False, "error": "No active view"}}
|
||||
else:
|
||||
# Check view type
|
||||
view_class = view.__class__.__name__
|
||||
if view_class not in ["View3DInventor", "View3DInventorPy"]:
|
||||
_result_ = {{"success": False, "error": f"Cannot capture from {{view_class}}"}}
|
||||
else:
|
||||
# Set view angle
|
||||
view_type = {view_type!r}
|
||||
if view_type == "FitAll":
|
||||
view.fitAll()
|
||||
elif view_type == "Isometric":
|
||||
view.viewIsometric()
|
||||
elif view_type == "Front":
|
||||
view.viewFront()
|
||||
elif view_type == "Back":
|
||||
view.viewRear()
|
||||
elif view_type == "Top":
|
||||
view.viewTop()
|
||||
elif view_type == "Bottom":
|
||||
view.viewBottom()
|
||||
elif view_type == "Left":
|
||||
view.viewLeft()
|
||||
elif view_type == "Right":
|
||||
view.viewRight()
|
||||
|
||||
# Capture screenshot
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
|
||||
temp_path = f.name
|
||||
|
||||
view.saveImage(temp_path, {width}, {height}, "Current")
|
||||
|
||||
with open(temp_path, "rb") as f:
|
||||
image_data = base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
os.unlink(temp_path)
|
||||
|
||||
_result_ = {{
|
||||
"success": True,
|
||||
"data": image_data,
|
||||
"format": "png",
|
||||
"width": {width},
|
||||
"height": {height},
|
||||
}}
|
||||
"""
|
||||
result = self._execute_via_queue(code, 30000)
|
||||
if result.get("success") and result.get("result"):
|
||||
return result["result"]
|
||||
return {"success": False, "error": result.get("error_message", "Unknown error")}
|
||||
|
||||
|
||||
# Backwards compatibility
|
||||
start = FreecadMCPPlugin
|
||||
@@ -0,0 +1,30 @@
|
||||
"""MCP prompt templates for FreeCAD.
|
||||
|
||||
This package contains reusable prompt templates for common FreeCAD tasks.
|
||||
Prompts guide users through complex workflows and provide best practices.
|
||||
|
||||
Available prompts:
|
||||
Design Workflows:
|
||||
- design_part: Guided parametric part design
|
||||
- create_sketch_guide: 2D sketch creation
|
||||
- boolean_operations_guide: Shape combination
|
||||
|
||||
Export/Import:
|
||||
- export_guide: Export to various formats
|
||||
- import_guide: Import from various formats
|
||||
|
||||
Analysis:
|
||||
- analyze_shape: Shape geometry analysis
|
||||
- debug_model: Model troubleshooting
|
||||
|
||||
Macro Development:
|
||||
- macro_development: Macro creation guide
|
||||
- python_api_reference: API quick reference
|
||||
|
||||
Troubleshooting:
|
||||
- troubleshooting: General issue resolution
|
||||
"""
|
||||
|
||||
from freecad_mcp.prompts.freecad import register_prompts
|
||||
|
||||
__all__ = ["register_prompts"]
|
||||
@@ -0,0 +1,739 @@
|
||||
"""FreeCAD MCP prompts for common CAD tasks.
|
||||
|
||||
This module provides reusable prompt templates that help Claude
|
||||
understand FreeCAD concepts and guide users through complex tasks.
|
||||
|
||||
Prompt Categories:
|
||||
- Design Workflows: Part design, sketching, modeling
|
||||
- Export/Import: File format handling
|
||||
- Analysis: Shape inspection, validation
|
||||
- Macro Development: Scripting guidance
|
||||
- Troubleshooting: Common issues and solutions
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
|
||||
def register_prompts(
|
||||
mcp: FastMCP,
|
||||
get_bridge: Callable[[], Coroutine[Any, Any, Any]], # noqa: ARG001
|
||||
) -> None:
|
||||
"""Register FreeCAD prompts with the MCP server.
|
||||
|
||||
Args:
|
||||
mcp: The FastMCP server instance.
|
||||
get_bridge: Async function to get the active bridge (unused but kept
|
||||
for interface consistency with other register functions).
|
||||
"""
|
||||
# =========================================================================
|
||||
# Design Workflow Prompts
|
||||
# =========================================================================
|
||||
|
||||
@mcp.prompt()
|
||||
async def design_part(
|
||||
description: str,
|
||||
units: str = "mm",
|
||||
) -> str:
|
||||
"""Generate a guided workflow for designing a parametric part.
|
||||
|
||||
Use this prompt when a user wants to create a new part from scratch.
|
||||
It provides step-by-step guidance for the PartDesign workflow.
|
||||
|
||||
Args:
|
||||
description: Natural language description of the desired part.
|
||||
units: Unit system to use (mm, cm, m, in).
|
||||
|
||||
Returns:
|
||||
Structured prompt guiding through part design.
|
||||
"""
|
||||
return f"""# FreeCAD Part Design Workflow
|
||||
|
||||
## Part Description
|
||||
{description}
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
### 1. Create a New Document
|
||||
First, create a new document for this part:
|
||||
- Use `create_document` with a descriptive name
|
||||
|
||||
### 2. Set Up PartDesign Body
|
||||
Create a PartDesign body to contain the parametric features:
|
||||
- Use `create_partdesign_body` to create the body container
|
||||
- This enables the parametric workflow with features
|
||||
|
||||
### 3. Create Base Sketch
|
||||
Design the base profile:
|
||||
- Use `create_sketch` on the XY plane (or appropriate plane)
|
||||
- Add geometry with `add_sketch_rectangle`, `add_sketch_circle`, etc.
|
||||
- Close the sketch when complete
|
||||
|
||||
### 4. Extrude the Base
|
||||
Create the base 3D shape:
|
||||
- Use `pad_sketch` to extrude the sketch
|
||||
- Specify length in {units}
|
||||
|
||||
### 5. Add Features
|
||||
Add additional features as needed:
|
||||
- `pocket_sketch` for cuts/holes
|
||||
- `fillet_edges` for rounded edges
|
||||
- `chamfer_edges` for beveled edges
|
||||
|
||||
### 6. Verify and Export
|
||||
When complete:
|
||||
- Use `inspect_object` to verify dimensions
|
||||
- Use `get_screenshot` to visualize the result
|
||||
- Export with `export_step` or `export_stl` as needed
|
||||
|
||||
## Units
|
||||
All dimensions should be specified in **{units}**.
|
||||
"""
|
||||
|
||||
@mcp.prompt()
|
||||
async def create_sketch_guide(
|
||||
shape_type: str = "rectangle",
|
||||
plane: str = "XY",
|
||||
) -> str:
|
||||
"""Guide for creating 2D sketches for part design.
|
||||
|
||||
Args:
|
||||
shape_type: Type of shape (rectangle, circle, polygon).
|
||||
plane: Sketch plane (XY, XZ, YZ).
|
||||
|
||||
Returns:
|
||||
Sketch creation guidance.
|
||||
"""
|
||||
return f"""# FreeCAD Sketch Creation Guide
|
||||
|
||||
## Target Shape: {shape_type}
|
||||
## Sketch Plane: {plane}
|
||||
|
||||
### Step 1: Create Sketch
|
||||
Use `create_sketch` with plane="{plane}" to start a new sketch.
|
||||
|
||||
### Step 2: Add Geometry
|
||||
|
||||
{"#### Rectangle" if shape_type == "rectangle" else ""}
|
||||
{"Use `add_sketch_rectangle` with:" if shape_type == "rectangle" else ""}
|
||||
{"- x, y: Starting corner position" if shape_type == "rectangle" else ""}
|
||||
{"- width, height: Rectangle dimensions" if shape_type == "rectangle" else ""}
|
||||
|
||||
{"#### Circle" if shape_type == "circle" else ""}
|
||||
{"Use `add_sketch_circle` with:" if shape_type == "circle" else ""}
|
||||
{"- x, y: Center position" if shape_type == "circle" else ""}
|
||||
{"- radius: Circle radius" if shape_type == "circle" else ""}
|
||||
|
||||
{"#### Custom Polygon" if shape_type == "polygon" else ""}
|
||||
{"Use `execute_python` with Part.makePolygon() for custom shapes." if shape_type == "polygon" else ""}
|
||||
|
||||
### Step 3: Constrain the Sketch
|
||||
For a fully constrained sketch:
|
||||
- All geometry should have defined positions
|
||||
- No free degrees of freedom
|
||||
|
||||
### Step 4: Close and Use
|
||||
The sketch can then be:
|
||||
- Padded (extruded) with `pad_sketch`
|
||||
- Pocketed (cut) with `pocket_sketch`
|
||||
- Revolved with `execute_python` using PartDesign Revolution
|
||||
"""
|
||||
|
||||
@mcp.prompt()
|
||||
async def boolean_operations_guide() -> str:
|
||||
"""Guide for performing boolean operations on shapes.
|
||||
|
||||
Returns:
|
||||
Boolean operations guidance.
|
||||
"""
|
||||
return """# FreeCAD Boolean Operations Guide
|
||||
|
||||
Boolean operations combine two or more shapes into a new shape.
|
||||
|
||||
## Available Operations
|
||||
|
||||
### 1. Fuse (Union)
|
||||
Combines two shapes into one:
|
||||
```
|
||||
boolean_operation(
|
||||
object1="Box",
|
||||
object2="Cylinder",
|
||||
operation="fuse",
|
||||
result_name="FusedShape"
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Cut (Difference)
|
||||
Removes the second shape from the first:
|
||||
```
|
||||
boolean_operation(
|
||||
object1="Box",
|
||||
object2="Cylinder",
|
||||
operation="cut",
|
||||
result_name="CutShape"
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Common (Intersection)
|
||||
Keeps only the overlapping region:
|
||||
```
|
||||
boolean_operation(
|
||||
object1="Box",
|
||||
object2="Cylinder",
|
||||
operation="common",
|
||||
result_name="CommonShape"
|
||||
)
|
||||
```
|
||||
|
||||
## Tips
|
||||
- Shapes must overlap for meaningful results
|
||||
- The original objects remain in the document
|
||||
- Use `set_object_visibility` to hide originals after operation
|
||||
- Recompute the document after boolean operations
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# Export/Import Prompts
|
||||
# =========================================================================
|
||||
|
||||
@mcp.prompt()
|
||||
async def export_guide(target_format: str = "STEP") -> str:
|
||||
"""Guide for exporting FreeCAD models to various formats.
|
||||
|
||||
Args:
|
||||
target_format: Target export format (STEP, STL, OBJ, IGES).
|
||||
|
||||
Returns:
|
||||
Export guidance for the specified format.
|
||||
"""
|
||||
format_info = {
|
||||
"STEP": {
|
||||
"tool": "export_step",
|
||||
"extension": ".step",
|
||||
"description": "Standard for exchanging 3D CAD data between systems",
|
||||
"best_for": "CAD interchange, preserves geometry precisely",
|
||||
"params": "file_path, object_names (optional)",
|
||||
},
|
||||
"STL": {
|
||||
"tool": "export_stl",
|
||||
"extension": ".stl",
|
||||
"description": "Triangulated mesh format",
|
||||
"best_for": "3D printing, mesh-based workflows",
|
||||
"params": "file_path, object_names (optional), mesh_tolerance (default 0.1)",
|
||||
},
|
||||
"OBJ": {
|
||||
"tool": "export_obj",
|
||||
"extension": ".obj",
|
||||
"description": "Wavefront OBJ mesh format",
|
||||
"best_for": "3D graphics, rendering, game engines",
|
||||
"params": "file_path, object_names (optional)",
|
||||
},
|
||||
"IGES": {
|
||||
"tool": "export_iges",
|
||||
"extension": ".iges",
|
||||
"description": "Initial Graphics Exchange Specification",
|
||||
"best_for": "Legacy CAD systems, surface data",
|
||||
"params": "file_path, object_names (optional)",
|
||||
},
|
||||
}
|
||||
|
||||
info = format_info.get(target_format.upper(), format_info["STEP"])
|
||||
|
||||
return f"""# FreeCAD Export Guide: {target_format.upper()}
|
||||
|
||||
## Format: {target_format.upper()} ({info["extension"]})
|
||||
{info["description"]}
|
||||
|
||||
**Best for:** {info["best_for"]}
|
||||
|
||||
## Export Command
|
||||
Use the `{info["tool"]}` tool with parameters:
|
||||
- {info["params"]}
|
||||
|
||||
## Example
|
||||
```python
|
||||
{info["tool"]}(
|
||||
file_path="/path/to/output{info["extension"]}",
|
||||
object_names=["Part1", "Part2"] # Optional: exports all if not specified
|
||||
)
|
||||
```
|
||||
|
||||
## Pre-Export Checklist
|
||||
1. Verify all objects are visible with `list_objects`
|
||||
2. Check object validity with `inspect_object`
|
||||
3. Recompute document if needed: `recompute_document`
|
||||
4. Consider using `fit_all` and `get_screenshot` to verify visually
|
||||
|
||||
## Post-Export
|
||||
- Verify the exported file exists
|
||||
- Check file size is reasonable
|
||||
- Test import in target application if possible
|
||||
"""
|
||||
|
||||
@mcp.prompt()
|
||||
async def import_guide(source_format: str = "STEP") -> str:
|
||||
"""Guide for importing models into FreeCAD.
|
||||
|
||||
Args:
|
||||
source_format: Source file format (STEP, STL).
|
||||
|
||||
Returns:
|
||||
Import guidance for the specified format.
|
||||
"""
|
||||
format_info = {
|
||||
"STEP": {
|
||||
"tool": "import_step",
|
||||
"description": "Imports precise CAD geometry",
|
||||
"notes": "Preserves feature boundaries, faces, and edges",
|
||||
},
|
||||
"STL": {
|
||||
"tool": "import_stl",
|
||||
"description": "Imports triangulated mesh",
|
||||
"notes": "Results in Mesh object, may need conversion for CAD operations",
|
||||
},
|
||||
}
|
||||
|
||||
info = format_info.get(source_format.upper(), format_info["STEP"])
|
||||
|
||||
return f"""# FreeCAD Import Guide: {source_format.upper()}
|
||||
|
||||
## Format: {source_format.upper()}
|
||||
{info["description"]}
|
||||
|
||||
**Notes:** {info["notes"]}
|
||||
|
||||
## Import Command
|
||||
Use the `{info["tool"]}` tool:
|
||||
```python
|
||||
{info["tool"]}(
|
||||
file_path="/path/to/file.{source_format.lower()}",
|
||||
doc_name="TargetDocument" # Optional
|
||||
)
|
||||
```
|
||||
|
||||
## Post-Import Steps
|
||||
1. List imported objects: `list_objects`
|
||||
2. Inspect geometry: `inspect_object` on each object
|
||||
3. Adjust view: `fit_all` to see all imported geometry
|
||||
4. Take screenshot: `get_screenshot` to verify import
|
||||
|
||||
## Common Issues
|
||||
- Large files may take time to process
|
||||
- Complex geometry may create many objects
|
||||
- STL meshes need conversion for boolean operations
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# Analysis Prompts
|
||||
# =========================================================================
|
||||
|
||||
@mcp.prompt()
|
||||
async def analyze_shape() -> str:
|
||||
"""Guide for analyzing shape geometry and properties.
|
||||
|
||||
Returns:
|
||||
Shape analysis guidance.
|
||||
"""
|
||||
return """# FreeCAD Shape Analysis Guide
|
||||
|
||||
## Quick Analysis
|
||||
Use `inspect_object` with `include_shape=True` to get:
|
||||
- Volume
|
||||
- Surface area
|
||||
- Bounding box
|
||||
- Vertex/edge/face counts
|
||||
- Validity status
|
||||
|
||||
## Detailed Analysis with Python
|
||||
|
||||
### Bounding Box
|
||||
```python
|
||||
execute_python('''
|
||||
obj = FreeCAD.ActiveDocument.getObject("ObjectName")
|
||||
bb = obj.Shape.BoundBox
|
||||
_result_ = {
|
||||
"min": [bb.XMin, bb.YMin, bb.ZMin],
|
||||
"max": [bb.XMax, bb.YMax, bb.ZMax],
|
||||
"size": [bb.XLength, bb.YLength, bb.ZLength],
|
||||
"center": [bb.Center.x, bb.Center.y, bb.Center.z]
|
||||
}
|
||||
''')
|
||||
```
|
||||
|
||||
### Center of Mass
|
||||
```python
|
||||
execute_python('''
|
||||
obj = FreeCAD.ActiveDocument.getObject("ObjectName")
|
||||
com = obj.Shape.CenterOfMass
|
||||
_result_ = {"x": com.x, "y": com.y, "z": com.z}
|
||||
''')
|
||||
```
|
||||
|
||||
### Moments of Inertia
|
||||
```python
|
||||
execute_python('''
|
||||
obj = FreeCAD.ActiveDocument.getObject("ObjectName")
|
||||
moi = obj.Shape.MatrixOfInertia
|
||||
_result_ = {
|
||||
"Ixx": moi.A11, "Iyy": moi.A22, "Izz": moi.A33,
|
||||
"Ixy": moi.A12, "Ixz": moi.A13, "Iyz": moi.A23
|
||||
}
|
||||
''')
|
||||
```
|
||||
|
||||
## Validation
|
||||
Check for geometry issues:
|
||||
```python
|
||||
execute_python('''
|
||||
obj = FreeCAD.ActiveDocument.getObject("ObjectName")
|
||||
shape = obj.Shape
|
||||
_result_ = {
|
||||
"is_valid": shape.isValid(),
|
||||
"is_closed": shape.isClosed() if hasattr(shape, 'isClosed') else None,
|
||||
"has_shape": shape.ShapeType != "Compound" or len(shape.Solids) > 0
|
||||
}
|
||||
''')
|
||||
```
|
||||
"""
|
||||
|
||||
@mcp.prompt()
|
||||
async def debug_model() -> str:
|
||||
"""Guide for debugging FreeCAD model issues.
|
||||
|
||||
Returns:
|
||||
Model debugging guidance.
|
||||
"""
|
||||
return """# FreeCAD Model Debugging Guide
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### 1. Recompute Errors
|
||||
**Symptom:** Objects show error state, model doesn't update
|
||||
**Solution:**
|
||||
```python
|
||||
recompute_document() # Force full recompute
|
||||
```
|
||||
|
||||
### 2. Invalid Shape
|
||||
**Symptom:** Boolean operations fail, export errors
|
||||
**Diagnosis:**
|
||||
```python
|
||||
execute_python('''
|
||||
obj = FreeCAD.ActiveDocument.getObject("ObjectName")
|
||||
_result_ = {
|
||||
"valid": obj.Shape.isValid(),
|
||||
"type": obj.Shape.ShapeType,
|
||||
"check": obj.Shape.check() if hasattr(obj.Shape, 'check') else "N/A"
|
||||
}
|
||||
''')
|
||||
```
|
||||
|
||||
### 3. Sketch Not Fully Constrained
|
||||
**Symptom:** Sketch geometry moves unexpectedly
|
||||
**Check constraints:**
|
||||
```python
|
||||
execute_python('''
|
||||
sketch = FreeCAD.ActiveDocument.getObject("SketchName")
|
||||
_result_ = {
|
||||
"dof": sketch.solve(), # Degrees of freedom
|
||||
"constraint_count": sketch.ConstraintCount,
|
||||
"geometry_count": sketch.GeometryCount
|
||||
}
|
||||
''')
|
||||
```
|
||||
|
||||
### 4. Object Dependencies
|
||||
**Symptom:** Can't delete object, unexpected behavior
|
||||
**Check dependencies:**
|
||||
```python
|
||||
inspect_object("ObjectName") # Check children and parents
|
||||
```
|
||||
|
||||
### 5. View Not Updating
|
||||
**Symptom:** Display doesn't match model
|
||||
**Solution:**
|
||||
```python
|
||||
fit_all() # Reset view
|
||||
get_screenshot() # Force view update
|
||||
```
|
||||
|
||||
## Diagnostic Workflow
|
||||
1. `list_objects` - See all objects and their states
|
||||
2. `inspect_object` on problematic objects
|
||||
3. `get_console_output` - Check for error messages
|
||||
4. `recompute_document` - Force update
|
||||
5. `get_screenshot` - Visual verification
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# Macro Development Prompts
|
||||
# =========================================================================
|
||||
|
||||
@mcp.prompt()
|
||||
async def macro_development() -> str:
|
||||
"""Guide for developing FreeCAD macros.
|
||||
|
||||
Returns:
|
||||
Macro development guidance.
|
||||
"""
|
||||
return """# FreeCAD Macro Development Guide
|
||||
|
||||
## Macro Structure
|
||||
A FreeCAD macro is a Python script that automates tasks.
|
||||
|
||||
### Basic Template
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Macro: MacroName
|
||||
# Description: What the macro does
|
||||
|
||||
import FreeCAD
|
||||
import FreeCADGui
|
||||
|
||||
def main():
|
||||
# Get active document
|
||||
doc = FreeCAD.ActiveDocument
|
||||
if doc is None:
|
||||
FreeCAD.Console.PrintError("No active document\\n")
|
||||
return
|
||||
|
||||
# Your code here
|
||||
|
||||
doc.recompute()
|
||||
FreeCAD.Console.PrintMessage("Macro completed\\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
## Creating a Macro
|
||||
Use `create_macro` to save a macro:
|
||||
```python
|
||||
create_macro(
|
||||
name="MyMacro",
|
||||
code="... macro code ...",
|
||||
description="What it does"
|
||||
)
|
||||
```
|
||||
|
||||
Or use a template:
|
||||
```python
|
||||
create_macro_from_template(
|
||||
template_name="part", # basic, part, sketch, gui, selection
|
||||
macro_name="MyPartMacro"
|
||||
)
|
||||
```
|
||||
|
||||
## Available Templates
|
||||
- **basic**: Minimal template
|
||||
- **part**: Part creation with primitives
|
||||
- **sketch**: 2D sketch operations
|
||||
- **gui**: GUI interaction with message boxes
|
||||
- **selection**: Working with selected objects
|
||||
|
||||
## Running Macros
|
||||
```python
|
||||
run_macro("MacroName")
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
1. Always check for active document
|
||||
2. Use FreeCAD.Console for output
|
||||
3. Call doc.recompute() after changes
|
||||
4. Handle exceptions gracefully
|
||||
5. Add descriptive comments
|
||||
"""
|
||||
|
||||
@mcp.prompt()
|
||||
async def python_api_reference() -> str:
|
||||
"""Quick reference for common FreeCAD Python API operations.
|
||||
|
||||
Returns:
|
||||
Python API reference.
|
||||
"""
|
||||
return """# FreeCAD Python API Quick Reference
|
||||
|
||||
## Document Operations
|
||||
```python
|
||||
# Create/get documents
|
||||
doc = FreeCAD.newDocument("Name")
|
||||
doc = FreeCAD.ActiveDocument
|
||||
doc = FreeCAD.getDocument("Name")
|
||||
|
||||
# Document methods
|
||||
doc.recompute()
|
||||
doc.save()
|
||||
doc.saveAs("/path/to/file.FCStd")
|
||||
```
|
||||
|
||||
## Object Operations
|
||||
```python
|
||||
# Create objects
|
||||
box = doc.addObject("Part::Box", "MyBox")
|
||||
cyl = doc.addObject("Part::Cylinder", "MyCyl")
|
||||
|
||||
# Get objects
|
||||
obj = doc.getObject("ObjectName")
|
||||
all_objs = doc.Objects
|
||||
|
||||
# Modify properties
|
||||
obj.Length = 100
|
||||
obj.Placement = FreeCAD.Placement(
|
||||
FreeCAD.Vector(x, y, z),
|
||||
FreeCAD.Rotation(axis, angle)
|
||||
)
|
||||
|
||||
# Delete
|
||||
doc.removeObject("ObjectName")
|
||||
```
|
||||
|
||||
## Part Module
|
||||
```python
|
||||
import Part
|
||||
|
||||
# Primitives
|
||||
box = Part.makeBox(l, w, h)
|
||||
cyl = Part.makeCylinder(r, h)
|
||||
sphere = Part.makeSphere(r)
|
||||
|
||||
# Boolean operations
|
||||
fused = shape1.fuse(shape2)
|
||||
cut = shape1.cut(shape2)
|
||||
common = shape1.common(shape2)
|
||||
|
||||
# Create from shape
|
||||
Part.show(shape, "Name")
|
||||
```
|
||||
|
||||
## Sketcher Module
|
||||
```python
|
||||
import Sketcher
|
||||
|
||||
# Create sketch
|
||||
sketch = doc.addObject("Sketcher::SketchObject", "Sketch")
|
||||
sketch.MapMode = "FlatFace"
|
||||
|
||||
# Add geometry
|
||||
sketch.addGeometry(Part.LineSegment(p1, p2))
|
||||
sketch.addGeometry(Part.Circle(center, normal, radius))
|
||||
|
||||
# Add constraints
|
||||
sketch.addConstraint(Sketcher.Constraint("Coincident", 0, 1, 1, 2))
|
||||
sketch.addConstraint(Sketcher.Constraint("Horizontal", 0))
|
||||
```
|
||||
|
||||
## GUI Operations
|
||||
```python
|
||||
import FreeCADGui as Gui
|
||||
|
||||
# View control
|
||||
view = Gui.ActiveDocument.ActiveView
|
||||
view.viewIsometric()
|
||||
view.fitAll()
|
||||
view.saveImage("/path/to/image.png", 800, 600)
|
||||
|
||||
# Object visibility
|
||||
obj.ViewObject.Visibility = True/False
|
||||
obj.ViewObject.ShapeColor = (r, g, b) # 0.0-1.0
|
||||
```
|
||||
|
||||
## Vectors and Placement
|
||||
```python
|
||||
# Vector operations
|
||||
v = FreeCAD.Vector(x, y, z)
|
||||
v.Length
|
||||
v.normalize()
|
||||
v1.cross(v2)
|
||||
v1.dot(v2)
|
||||
|
||||
# Placement
|
||||
p = FreeCAD.Placement()
|
||||
p.Base = FreeCAD.Vector(x, y, z)
|
||||
p.Rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), 45)
|
||||
```
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# Troubleshooting Prompts
|
||||
# =========================================================================
|
||||
|
||||
@mcp.prompt()
|
||||
async def troubleshooting() -> str:
|
||||
"""General troubleshooting guide for FreeCAD MCP.
|
||||
|
||||
Returns:
|
||||
Troubleshooting guidance.
|
||||
"""
|
||||
return """# FreeCAD MCP Troubleshooting Guide
|
||||
|
||||
## Connection Issues
|
||||
|
||||
### Cannot Connect to FreeCAD
|
||||
1. Verify FreeCAD is running (for socket/xmlrpc modes)
|
||||
2. Check the MCP plugin is started in FreeCAD
|
||||
3. Verify port numbers match (default: 9876 socket, 9875 xmlrpc)
|
||||
|
||||
**Check status:**
|
||||
```python
|
||||
get_connection_status()
|
||||
```
|
||||
|
||||
### Connection Drops
|
||||
- FreeCAD may be busy with long operations
|
||||
- Try increasing timeout values
|
||||
- Check FreeCAD console for errors
|
||||
|
||||
## Execution Issues
|
||||
|
||||
### Code Execution Timeout
|
||||
- Increase timeout_ms parameter
|
||||
- Break complex operations into smaller steps
|
||||
- Check for infinite loops in code
|
||||
|
||||
### No Result Returned
|
||||
- Ensure you set `_result_ = value` in your code
|
||||
- Check for exceptions in stderr
|
||||
|
||||
**Debug execution:**
|
||||
```python
|
||||
execute_python('''
|
||||
try:
|
||||
# Your code
|
||||
_result_ = {"success": True, "data": result}
|
||||
except Exception as e:
|
||||
_result_ = {"success": False, "error": str(e)}
|
||||
''')
|
||||
```
|
||||
|
||||
## GUI Issues
|
||||
|
||||
### Screenshots Fail
|
||||
- Ensure GUI mode is available: `get_freecad_version()`
|
||||
- Check for active document and view
|
||||
- Verify view type supports screenshots
|
||||
|
||||
### View Not Updating
|
||||
```python
|
||||
recompute_document()
|
||||
fit_all()
|
||||
```
|
||||
|
||||
## Model Issues
|
||||
|
||||
### Boolean Operation Fails
|
||||
- Check shapes are valid
|
||||
- Ensure shapes overlap
|
||||
- Try with simpler geometry first
|
||||
|
||||
### Export Fails
|
||||
- Verify objects have valid shapes
|
||||
- Check file path is writable
|
||||
- Ensure correct format for geometry type
|
||||
|
||||
## Getting Help
|
||||
1. Check console output: `get_console_output()`
|
||||
2. Inspect problematic objects: `inspect_object()`
|
||||
3. Verify document state: `list_documents()`, `list_objects()`
|
||||
"""
|
||||
@@ -0,0 +1,2 @@
|
||||
# PEP 561 marker file
|
||||
# This file indicates that this package supports type checking
|
||||
@@ -0,0 +1,23 @@
|
||||
"""MCP resource implementations for FreeCAD.
|
||||
|
||||
This package contains all MCP resource definitions for querying FreeCAD state.
|
||||
Resources provide read-only access to FreeCAD's current state via URI-addressable
|
||||
endpoints.
|
||||
|
||||
Available resources:
|
||||
- freecad://version - FreeCAD version information
|
||||
- freecad://status - Connection and runtime status
|
||||
- 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} - Object details
|
||||
- freecad://workbenches - Available workbenches
|
||||
- freecad://workbenches/active - Currently active workbench
|
||||
- freecad://macros - Available macros
|
||||
- freecad://console - Recent console output
|
||||
- freecad://active-document - Currently active document
|
||||
"""
|
||||
|
||||
from freecad_mcp.resources.freecad import register_resources
|
||||
|
||||
__all__ = ["register_resources"]
|
||||
@@ -0,0 +1,898 @@
|
||||
"""FreeCAD MCP resources for exposing FreeCAD state.
|
||||
|
||||
This module provides MCP resources that expose FreeCAD's current state
|
||||
as read-only data. Resources are URI-addressable data that Claude can
|
||||
access to understand the current FreeCAD environment.
|
||||
|
||||
Resource URIs:
|
||||
- freecad://capabilities - Complete list of all available tools/resources
|
||||
- freecad://version - FreeCAD version information
|
||||
- freecad://status - Connection and runtime status
|
||||
- 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} - Object details
|
||||
- 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
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def register_resources(mcp, get_bridge) -> None:
|
||||
"""Register FreeCAD resources with the MCP server.
|
||||
|
||||
Args:
|
||||
mcp: The FastMCP server instance.
|
||||
get_bridge: Async function to get the active bridge.
|
||||
"""
|
||||
|
||||
@mcp.resource("freecad://version")
|
||||
async def resource_version() -> str:
|
||||
"""Get FreeCAD version and build information.
|
||||
|
||||
Returns:
|
||||
JSON string containing:
|
||||
- version: FreeCAD version string
|
||||
- build_date: Build timestamp
|
||||
- python_version: Python interpreter version
|
||||
- gui_available: Whether GUI mode is active
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
version_info = await bridge.get_freecad_version()
|
||||
return json.dumps(version_info, indent=2)
|
||||
|
||||
@mcp.resource("freecad://status")
|
||||
async def resource_status() -> str:
|
||||
"""Get current FreeCAD connection and runtime status.
|
||||
|
||||
Returns:
|
||||
JSON string containing:
|
||||
- connected: Connection state
|
||||
- mode: Bridge mode (embedded, xmlrpc, socket)
|
||||
- freecad_version: Version string
|
||||
- gui_available: GUI availability
|
||||
- last_ping_ms: Connection latency
|
||||
- error: Any error message
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
status = await bridge.get_status()
|
||||
return json.dumps(
|
||||
{
|
||||
"connected": status.connected,
|
||||
"mode": status.mode,
|
||||
"freecad_version": status.freecad_version,
|
||||
"gui_available": status.gui_available,
|
||||
"last_ping_ms": status.last_ping_ms,
|
||||
"error": status.error,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
@mcp.resource("freecad://documents")
|
||||
async def resource_documents() -> str:
|
||||
"""Get list of all open FreeCAD documents.
|
||||
|
||||
Returns:
|
||||
JSON string containing list of documents, each with:
|
||||
- name: Internal document name
|
||||
- label: Display label
|
||||
- path: File path (null if unsaved)
|
||||
- object_count: Number of objects
|
||||
- is_modified: Has unsaved changes
|
||||
- active_object: Currently selected object
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
docs = await bridge.get_documents()
|
||||
doc_list = [
|
||||
{
|
||||
"name": doc.name,
|
||||
"label": doc.label,
|
||||
"path": doc.path,
|
||||
"object_count": len(doc.objects),
|
||||
"is_modified": doc.is_modified,
|
||||
"active_object": doc.active_object,
|
||||
}
|
||||
for doc in docs
|
||||
]
|
||||
return json.dumps(doc_list, indent=2)
|
||||
|
||||
@mcp.resource("freecad://documents/{name}")
|
||||
async def resource_document(name: str) -> str:
|
||||
"""Get detailed information about a specific document.
|
||||
|
||||
Args:
|
||||
name: Document name to query.
|
||||
|
||||
Returns:
|
||||
JSON string containing:
|
||||
- name: Internal document name
|
||||
- label: Display label
|
||||
- path: File path (null if unsaved)
|
||||
- objects: List of object names
|
||||
- is_modified: Has unsaved changes
|
||||
- active_object: Currently selected object
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
docs = await bridge.get_documents()
|
||||
|
||||
for doc in docs:
|
||||
if doc.name == name:
|
||||
return json.dumps(
|
||||
{
|
||||
"name": doc.name,
|
||||
"label": doc.label,
|
||||
"path": doc.path,
|
||||
"objects": doc.objects,
|
||||
"is_modified": doc.is_modified,
|
||||
"active_object": doc.active_object,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
return json.dumps({"error": f"Document '{name}' not found"}, indent=2)
|
||||
|
||||
@mcp.resource("freecad://documents/{name}/objects")
|
||||
async def resource_document_objects(name: str) -> str:
|
||||
"""Get list of objects in a specific document.
|
||||
|
||||
Args:
|
||||
name: Document name to query.
|
||||
|
||||
Returns:
|
||||
JSON string containing list of objects, each with:
|
||||
- name: Object name
|
||||
- label: Display label
|
||||
- type_id: FreeCAD type identifier
|
||||
- visibility: Whether object is visible
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
objects = await bridge.get_objects(doc_name=name)
|
||||
obj_list = [
|
||||
{
|
||||
"name": obj.name,
|
||||
"label": obj.label,
|
||||
"type_id": obj.type_id,
|
||||
"visibility": obj.visibility,
|
||||
}
|
||||
for obj in objects
|
||||
]
|
||||
return json.dumps(obj_list, indent=2)
|
||||
|
||||
@mcp.resource("freecad://objects/{doc_name}/{obj_name}")
|
||||
async def resource_object(doc_name: str, obj_name: str) -> str:
|
||||
"""Get detailed information about a specific object.
|
||||
|
||||
Args:
|
||||
doc_name: Document containing the object.
|
||||
obj_name: Object name to query.
|
||||
|
||||
Returns:
|
||||
JSON string containing:
|
||||
- name: Object name
|
||||
- label: Display label
|
||||
- type_id: FreeCAD type identifier
|
||||
- properties: Dictionary of property values
|
||||
- shape_info: Shape geometry (if applicable)
|
||||
- children: Dependent object names
|
||||
- parents: Parent object names
|
||||
- visibility: Display visibility
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
obj = await bridge.get_object(obj_name, doc_name=doc_name)
|
||||
|
||||
# Filter properties to only include serializable values
|
||||
safe_properties = _make_json_safe(obj.properties)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"name": obj.name,
|
||||
"label": obj.label,
|
||||
"type_id": obj.type_id,
|
||||
"properties": safe_properties,
|
||||
"shape_info": obj.shape_info,
|
||||
"children": obj.children,
|
||||
"parents": obj.parents,
|
||||
"visibility": obj.visibility,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
@mcp.resource("freecad://workbenches")
|
||||
async def resource_workbenches() -> str:
|
||||
"""Get list of available FreeCAD workbenches.
|
||||
|
||||
Returns:
|
||||
JSON string containing list of workbenches, each with:
|
||||
- name: Workbench internal name
|
||||
- label: Display label
|
||||
- is_active: Whether currently active
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
workbenches = await bridge.get_workbenches()
|
||||
wb_list = [
|
||||
{
|
||||
"name": wb.name,
|
||||
"label": wb.label,
|
||||
"is_active": wb.is_active,
|
||||
}
|
||||
for wb in workbenches
|
||||
]
|
||||
return json.dumps(wb_list, indent=2)
|
||||
|
||||
@mcp.resource("freecad://workbenches/active")
|
||||
async def resource_active_workbench() -> str:
|
||||
"""Get the currently active workbench.
|
||||
|
||||
Returns:
|
||||
JSON string containing active workbench info or null.
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
workbenches = await bridge.get_workbenches()
|
||||
for wb in workbenches:
|
||||
if wb.is_active:
|
||||
return json.dumps(
|
||||
{
|
||||
"name": wb.name,
|
||||
"label": wb.label,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
return json.dumps(None)
|
||||
|
||||
@mcp.resource("freecad://macros")
|
||||
async def resource_macros() -> str:
|
||||
"""Get list of available FreeCAD macros.
|
||||
|
||||
Returns:
|
||||
JSON string containing list of macros, each with:
|
||||
- name: Macro name (without extension)
|
||||
- path: Full file path
|
||||
- description: Macro description
|
||||
- is_system: Whether it's a system macro
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
macros = await bridge.get_macros()
|
||||
macro_list = [
|
||||
{
|
||||
"name": macro.name,
|
||||
"path": macro.path,
|
||||
"description": macro.description,
|
||||
"is_system": macro.is_system,
|
||||
}
|
||||
for macro in macros
|
||||
]
|
||||
return json.dumps(macro_list, indent=2)
|
||||
|
||||
@mcp.resource("freecad://console")
|
||||
async def resource_console() -> str:
|
||||
"""Get recent FreeCAD console output.
|
||||
|
||||
Returns:
|
||||
JSON string containing:
|
||||
- lines: List of console output lines
|
||||
- count: Number of lines
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
lines = await bridge.get_console_output(lines=100)
|
||||
return json.dumps(
|
||||
{
|
||||
"lines": lines,
|
||||
"count": len(lines),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
@mcp.resource("freecad://active-document")
|
||||
async def resource_active_document() -> str:
|
||||
"""Get the currently active document.
|
||||
|
||||
Returns:
|
||||
JSON string containing active document info or null.
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
doc = await bridge.get_active_document()
|
||||
if doc is None:
|
||||
return json.dumps(None)
|
||||
return json.dumps(
|
||||
{
|
||||
"name": doc.name,
|
||||
"label": doc.label,
|
||||
"path": doc.path,
|
||||
"objects": doc.objects,
|
||||
"is_modified": doc.is_modified,
|
||||
"active_object": doc.active_object,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
@mcp.resource("freecad://capabilities")
|
||||
async def resource_capabilities() -> str:
|
||||
"""Get comprehensive list of all MCP capabilities.
|
||||
|
||||
This resource provides a complete catalog of all available tools,
|
||||
resources, and prompts. Use this to discover what functionality
|
||||
is available when working with the FreeCAD MCP server.
|
||||
|
||||
Returns:
|
||||
JSON string containing:
|
||||
- tools: Dict of tool categories with tool definitions
|
||||
- resources: List of available resource URIs
|
||||
- prompts: List of available prompt names
|
||||
- examples: Common usage patterns
|
||||
"""
|
||||
capabilities = {
|
||||
"description": "FreeCAD MCP Server - Control FreeCAD via Model Context Protocol",
|
||||
"tools": {
|
||||
"execution": {
|
||||
"description": "Execute Python code and access console",
|
||||
"tools": [
|
||||
{
|
||||
"name": "execute_python",
|
||||
"description": "Execute arbitrary Python code in FreeCAD's context. Use _result_ = value to return data.",
|
||||
"key_params": ["code", "timeout_ms"],
|
||||
},
|
||||
{
|
||||
"name": "get_console_output",
|
||||
"description": "Get recent FreeCAD console output for debugging",
|
||||
"key_params": ["lines"],
|
||||
},
|
||||
{
|
||||
"name": "get_console_log",
|
||||
"description": "Alternative console log access",
|
||||
"key_params": ["lines"],
|
||||
},
|
||||
{
|
||||
"name": "get_freecad_version",
|
||||
"description": "Get FreeCAD version, build date, Python version",
|
||||
"key_params": [],
|
||||
},
|
||||
{
|
||||
"name": "get_connection_status",
|
||||
"description": "Check MCP bridge connection status and latency",
|
||||
"key_params": [],
|
||||
},
|
||||
{
|
||||
"name": "get_mcp_server_environment",
|
||||
"description": "Get MCP server environment info (OS, hostname, Docker detection)",
|
||||
"key_params": [],
|
||||
},
|
||||
],
|
||||
},
|
||||
"documents": {
|
||||
"description": "Document management",
|
||||
"tools": [
|
||||
{
|
||||
"name": "list_documents",
|
||||
"description": "List all open FreeCAD documents",
|
||||
"key_params": [],
|
||||
},
|
||||
{
|
||||
"name": "get_active_document",
|
||||
"description": "Get info about currently active document",
|
||||
"key_params": [],
|
||||
},
|
||||
{
|
||||
"name": "create_document",
|
||||
"description": "Create a new FreeCAD document",
|
||||
"key_params": ["name"],
|
||||
},
|
||||
{
|
||||
"name": "open_document",
|
||||
"description": "Open an existing .FCStd file",
|
||||
"key_params": ["path"],
|
||||
},
|
||||
{
|
||||
"name": "save_document",
|
||||
"description": "Save document to disk",
|
||||
"key_params": ["doc_name", "path"],
|
||||
},
|
||||
{
|
||||
"name": "close_document",
|
||||
"description": "Close a document",
|
||||
"key_params": ["doc_name"],
|
||||
},
|
||||
{
|
||||
"name": "recompute_document",
|
||||
"description": "Force recomputation of document features",
|
||||
"key_params": ["doc_name"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"objects": {
|
||||
"description": "Object creation and manipulation",
|
||||
"tools": [
|
||||
{
|
||||
"name": "list_objects",
|
||||
"description": "List all objects in a document",
|
||||
"key_params": ["doc_name"],
|
||||
},
|
||||
{
|
||||
"name": "inspect_object",
|
||||
"description": "Get detailed info about an object",
|
||||
"key_params": ["object_name", "doc_name"],
|
||||
},
|
||||
{
|
||||
"name": "create_box",
|
||||
"description": "Create Part::Box primitive",
|
||||
"key_params": ["length", "width", "height"],
|
||||
},
|
||||
{
|
||||
"name": "create_cylinder",
|
||||
"description": "Create Part::Cylinder primitive",
|
||||
"key_params": ["radius", "height"],
|
||||
},
|
||||
{
|
||||
"name": "create_sphere",
|
||||
"description": "Create Part::Sphere primitive",
|
||||
"key_params": ["radius"],
|
||||
},
|
||||
{
|
||||
"name": "create_cone",
|
||||
"description": "Create Part::Cone primitive",
|
||||
"key_params": ["radius1", "radius2", "height"],
|
||||
},
|
||||
{
|
||||
"name": "create_torus",
|
||||
"description": "Create Part::Torus primitive",
|
||||
"key_params": ["radius1", "radius2"],
|
||||
},
|
||||
{
|
||||
"name": "boolean_operation",
|
||||
"description": "Union, cut, or intersection operations",
|
||||
"key_params": ["operation", "object1", "object2"],
|
||||
},
|
||||
{
|
||||
"name": "edit_object",
|
||||
"description": "Modify object properties",
|
||||
"key_params": ["object_name", "properties"],
|
||||
},
|
||||
{
|
||||
"name": "delete_object",
|
||||
"description": "Delete an object",
|
||||
"key_params": ["object_name"],
|
||||
},
|
||||
{
|
||||
"name": "set_placement",
|
||||
"description": "Set object position and rotation",
|
||||
"key_params": ["object_name", "x", "y", "z"],
|
||||
},
|
||||
{
|
||||
"name": "copy_object",
|
||||
"description": "Create a copy of an object",
|
||||
"key_params": ["object_name"],
|
||||
},
|
||||
{
|
||||
"name": "mirror_object",
|
||||
"description": "Mirror object across a plane",
|
||||
"key_params": ["object_name", "plane"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"selection": {
|
||||
"description": "Selection management",
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_selection",
|
||||
"description": "Get currently selected objects",
|
||||
"key_params": ["doc_name"],
|
||||
},
|
||||
{
|
||||
"name": "set_selection",
|
||||
"description": "Select specific objects",
|
||||
"key_params": ["object_names"],
|
||||
},
|
||||
{
|
||||
"name": "clear_selection",
|
||||
"description": "Clear current selection",
|
||||
"key_params": [],
|
||||
},
|
||||
],
|
||||
},
|
||||
"partdesign": {
|
||||
"description": "Parametric modeling with PartDesign workbench",
|
||||
"tools": [
|
||||
{
|
||||
"name": "create_partdesign_body",
|
||||
"description": "Create a PartDesign::Body container",
|
||||
"key_params": ["name"],
|
||||
},
|
||||
{
|
||||
"name": "create_sketch",
|
||||
"description": "Create sketch on plane or face",
|
||||
"key_params": ["body_name", "plane"],
|
||||
},
|
||||
{
|
||||
"name": "add_sketch_rectangle",
|
||||
"description": "Add rectangle to sketch",
|
||||
"key_params": ["sketch_name", "x", "y", "width", "height"],
|
||||
},
|
||||
{
|
||||
"name": "add_sketch_circle",
|
||||
"description": "Add circle to sketch",
|
||||
"key_params": ["sketch_name", "x", "y", "radius"],
|
||||
},
|
||||
{
|
||||
"name": "add_sketch_line",
|
||||
"description": "Add line to sketch",
|
||||
"key_params": ["sketch_name", "x1", "y1", "x2", "y2"],
|
||||
},
|
||||
{
|
||||
"name": "add_sketch_arc",
|
||||
"description": "Add arc to sketch",
|
||||
"key_params": [
|
||||
"sketch_name",
|
||||
"center_x",
|
||||
"center_y",
|
||||
"radius",
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "add_sketch_point",
|
||||
"description": "Add point to sketch (for holes)",
|
||||
"key_params": ["sketch_name", "x", "y"],
|
||||
},
|
||||
{
|
||||
"name": "pad_sketch",
|
||||
"description": "Extrude sketch (additive)",
|
||||
"key_params": ["body_name", "sketch_name", "length"],
|
||||
},
|
||||
{
|
||||
"name": "pocket_sketch",
|
||||
"description": "Cut using sketch (subtractive)",
|
||||
"key_params": ["body_name", "sketch_name", "length"],
|
||||
},
|
||||
{
|
||||
"name": "revolution_sketch",
|
||||
"description": "Revolve sketch around axis",
|
||||
"key_params": ["body_name", "sketch_name", "axis", "angle"],
|
||||
},
|
||||
{
|
||||
"name": "create_hole",
|
||||
"description": "Create parametric hole feature",
|
||||
"key_params": [
|
||||
"body_name",
|
||||
"sketch_name",
|
||||
"diameter",
|
||||
"depth",
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "fillet_edges",
|
||||
"description": "Add fillets to edges",
|
||||
"key_params": ["body_name", "edges", "radius"],
|
||||
},
|
||||
{
|
||||
"name": "chamfer_edges",
|
||||
"description": "Add chamfers to edges",
|
||||
"key_params": ["body_name", "edges", "size"],
|
||||
},
|
||||
{
|
||||
"name": "loft_sketches",
|
||||
"description": "Create loft between sketches",
|
||||
"key_params": ["body_name", "sketch_names"],
|
||||
},
|
||||
{
|
||||
"name": "sweep_sketch",
|
||||
"description": "Sweep sketch along path",
|
||||
"key_params": [
|
||||
"body_name",
|
||||
"profile_sketch",
|
||||
"path_sketch",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"patterns": {
|
||||
"description": "Pattern and transform features",
|
||||
"tools": [
|
||||
{
|
||||
"name": "linear_pattern",
|
||||
"description": "Create linear pattern",
|
||||
"key_params": [
|
||||
"body_name",
|
||||
"feature_name",
|
||||
"direction",
|
||||
"count",
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "polar_pattern",
|
||||
"description": "Create circular/polar pattern",
|
||||
"key_params": [
|
||||
"body_name",
|
||||
"feature_name",
|
||||
"axis",
|
||||
"count",
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "mirrored_feature",
|
||||
"description": "Mirror feature across plane",
|
||||
"key_params": ["body_name", "feature_name", "plane"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"view": {
|
||||
"description": "View and GUI control (some require GUI mode)",
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_screenshot",
|
||||
"description": "Capture 3D view screenshot (GUI only)",
|
||||
"key_params": ["file_path", "width", "height"],
|
||||
},
|
||||
{
|
||||
"name": "set_view_angle",
|
||||
"description": "Set camera to standard views",
|
||||
"key_params": ["angle"],
|
||||
},
|
||||
{
|
||||
"name": "fit_all",
|
||||
"description": "Zoom to fit all objects",
|
||||
"key_params": [],
|
||||
},
|
||||
{
|
||||
"name": "zoom_in",
|
||||
"description": "Zoom in",
|
||||
"key_params": ["factor"],
|
||||
},
|
||||
{
|
||||
"name": "zoom_out",
|
||||
"description": "Zoom out",
|
||||
"key_params": ["factor"],
|
||||
},
|
||||
{
|
||||
"name": "set_object_visibility",
|
||||
"description": "Show/hide objects (GUI only)",
|
||||
"key_params": ["object_name", "visible"],
|
||||
},
|
||||
{
|
||||
"name": "set_display_mode",
|
||||
"description": "Set display mode (wireframe, shaded)",
|
||||
"key_params": ["object_name", "mode"],
|
||||
},
|
||||
{
|
||||
"name": "set_object_color",
|
||||
"description": "Change object color (GUI only)",
|
||||
"key_params": ["object_name", "r", "g", "b"],
|
||||
},
|
||||
{
|
||||
"name": "list_workbenches",
|
||||
"description": "List available workbenches",
|
||||
"key_params": [],
|
||||
},
|
||||
{
|
||||
"name": "activate_workbench",
|
||||
"description": "Switch workbench",
|
||||
"key_params": ["workbench_name"],
|
||||
},
|
||||
{
|
||||
"name": "recompute",
|
||||
"description": "Recompute document",
|
||||
"key_params": ["doc_name"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"undo_redo": {
|
||||
"description": "Undo/redo operations",
|
||||
"tools": [
|
||||
{
|
||||
"name": "undo",
|
||||
"description": "Undo last operation",
|
||||
"key_params": ["doc_name"],
|
||||
},
|
||||
{
|
||||
"name": "redo",
|
||||
"description": "Redo undone operation",
|
||||
"key_params": ["doc_name"],
|
||||
},
|
||||
{
|
||||
"name": "get_undo_redo_status",
|
||||
"description": "Get available undo/redo operations",
|
||||
"key_params": ["doc_name"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"export_import": {
|
||||
"description": "File export and import",
|
||||
"tools": [
|
||||
{
|
||||
"name": "export_step",
|
||||
"description": "Export to STEP format",
|
||||
"key_params": ["object_names", "file_path"],
|
||||
},
|
||||
{
|
||||
"name": "export_stl",
|
||||
"description": "Export to STL format (3D printing)",
|
||||
"key_params": ["object_names", "file_path"],
|
||||
},
|
||||
{
|
||||
"name": "export_3mf",
|
||||
"description": "Export to 3MF format",
|
||||
"key_params": ["object_names", "file_path"],
|
||||
},
|
||||
{
|
||||
"name": "export_obj",
|
||||
"description": "Export to OBJ format",
|
||||
"key_params": ["object_names", "file_path"],
|
||||
},
|
||||
{
|
||||
"name": "export_iges",
|
||||
"description": "Export to IGES format",
|
||||
"key_params": ["object_names", "file_path"],
|
||||
},
|
||||
{
|
||||
"name": "import_step",
|
||||
"description": "Import STEP file",
|
||||
"key_params": ["file_path"],
|
||||
},
|
||||
{
|
||||
"name": "import_stl",
|
||||
"description": "Import STL file",
|
||||
"key_params": ["file_path"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"macros": {
|
||||
"description": "Macro management",
|
||||
"tools": [
|
||||
{
|
||||
"name": "list_macros",
|
||||
"description": "List available macros",
|
||||
"key_params": [],
|
||||
},
|
||||
{
|
||||
"name": "run_macro",
|
||||
"description": "Execute a macro",
|
||||
"key_params": ["macro_name"],
|
||||
},
|
||||
{
|
||||
"name": "create_macro",
|
||||
"description": "Create new macro",
|
||||
"key_params": ["macro_name", "code"],
|
||||
},
|
||||
{
|
||||
"name": "read_macro",
|
||||
"description": "Read macro source code",
|
||||
"key_params": ["macro_name"],
|
||||
},
|
||||
{
|
||||
"name": "delete_macro",
|
||||
"description": "Delete a macro",
|
||||
"key_params": ["macro_name"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"parts_library": {
|
||||
"description": "Parts library access",
|
||||
"tools": [
|
||||
{
|
||||
"name": "list_parts_library",
|
||||
"description": "List parts in library",
|
||||
"key_params": [],
|
||||
},
|
||||
{
|
||||
"name": "insert_part_from_library",
|
||||
"description": "Insert part from library",
|
||||
"key_params": ["part_path"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"uri": "freecad://capabilities",
|
||||
"description": "This resource - lists all available capabilities",
|
||||
},
|
||||
{
|
||||
"uri": "freecad://version",
|
||||
"description": "FreeCAD version and build information",
|
||||
},
|
||||
{
|
||||
"uri": "freecad://status",
|
||||
"description": "Connection status, mode, GUI availability",
|
||||
},
|
||||
{
|
||||
"uri": "freecad://documents",
|
||||
"description": "List of all open documents",
|
||||
},
|
||||
{
|
||||
"uri": "freecad://documents/{name}",
|
||||
"description": "Details of a specific document",
|
||||
},
|
||||
{
|
||||
"uri": "freecad://documents/{name}/objects",
|
||||
"description": "Objects in a specific document",
|
||||
},
|
||||
{
|
||||
"uri": "freecad://objects/{doc_name}/{obj_name}",
|
||||
"description": "Detailed object info with properties",
|
||||
},
|
||||
{
|
||||
"uri": "freecad://active-document",
|
||||
"description": "Currently active document",
|
||||
},
|
||||
{
|
||||
"uri": "freecad://workbenches",
|
||||
"description": "Available FreeCAD workbenches",
|
||||
},
|
||||
{
|
||||
"uri": "freecad://workbenches/active",
|
||||
"description": "Currently active workbench",
|
||||
},
|
||||
{
|
||||
"uri": "freecad://macros",
|
||||
"description": "Available FreeCAD macros",
|
||||
},
|
||||
{
|
||||
"uri": "freecad://console",
|
||||
"description": "Recent console output (debugging)",
|
||||
},
|
||||
],
|
||||
"prompts": [
|
||||
{
|
||||
"name": "freecad-help",
|
||||
"description": "Get help on FreeCAD MCP capabilities",
|
||||
},
|
||||
{
|
||||
"name": "create-parametric-part",
|
||||
"description": "Guide for creating parametric parts",
|
||||
},
|
||||
{
|
||||
"name": "debug-model",
|
||||
"description": "Help debug model issues",
|
||||
},
|
||||
],
|
||||
"examples": {
|
||||
"debug_macro": {
|
||||
"description": "Debug a macro by checking console output",
|
||||
"steps": [
|
||||
"Use get_console_output(lines=50) to see recent errors",
|
||||
"Use execute_python to inspect document state",
|
||||
],
|
||||
},
|
||||
"create_simple_part": {
|
||||
"description": "Create a basic parametric part",
|
||||
"steps": [
|
||||
"create_document(name='MyPart')",
|
||||
"create_partdesign_body(name='Body')",
|
||||
"create_sketch(body_name='Body', plane='XY_Plane')",
|
||||
"add_sketch_rectangle(...)",
|
||||
"pad_sketch(...)",
|
||||
],
|
||||
},
|
||||
"export_for_printing": {
|
||||
"description": "Export model for 3D printing",
|
||||
"steps": [
|
||||
"export_stl(object_names=['Body'], file_path='...')",
|
||||
"Or export_3mf for color/material support",
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
return json.dumps(capabilities, indent=2)
|
||||
|
||||
|
||||
def _make_json_safe(obj: Any) -> Any:
|
||||
"""Convert an object to be JSON serializable.
|
||||
|
||||
Args:
|
||||
obj: Object to convert.
|
||||
|
||||
Returns:
|
||||
JSON-safe representation of the object.
|
||||
"""
|
||||
if obj is None:
|
||||
return None
|
||||
if isinstance(obj, str | int | float | bool):
|
||||
return obj
|
||||
if isinstance(obj, list | tuple):
|
||||
return [_make_json_safe(item) for item in obj]
|
||||
if isinstance(obj, dict):
|
||||
return {str(k): _make_json_safe(v) for k, v in obj.items()}
|
||||
# Convert other types to string representation
|
||||
return str(obj)
|
||||
@@ -0,0 +1,190 @@
|
||||
"""FreeCAD MCP Server - Main entry point.
|
||||
|
||||
This module provides the main MCP server implementation for FreeCAD
|
||||
integration with AI assistants (Claude, GPT, and other MCP-compatible tools).
|
||||
It exposes tools, resources, and prompts for interacting with FreeCAD.
|
||||
|
||||
Features:
|
||||
- Full Python console access (GUI and headless modes)
|
||||
- Document and object management
|
||||
- PartDesign workflow (sketches, pads, pockets, fillets)
|
||||
- Import/export (STEP, STL, OBJ, IGES)
|
||||
- Macro management
|
||||
- Screenshot capture
|
||||
- Multiple connection modes (embedded, XML-RPC, socket)
|
||||
|
||||
Example:
|
||||
Run as a module::
|
||||
|
||||
$ python -m freecad_mcp.server
|
||||
|
||||
Or use the installed command::
|
||||
|
||||
$ freecad-mcp
|
||||
|
||||
With environment variables::
|
||||
|
||||
$ FREECAD_MODE=socket FREECAD_HOST=localhost freecad-mcp
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from freecad_mcp.config import FreecadMode, TransportType, get_config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from freecad_mcp.bridge.base import FreecadBridge
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global bridge instance (initialized on startup via lifespan)
|
||||
_bridge: Any = None
|
||||
|
||||
|
||||
async def get_bridge() -> "FreecadBridge":
|
||||
"""Get the active FreeCAD bridge.
|
||||
|
||||
Returns:
|
||||
The active FreecadBridge instance.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If bridge is not initialized.
|
||||
"""
|
||||
if _bridge is None:
|
||||
msg = "FreeCAD bridge not initialized"
|
||||
raise RuntimeError(msg)
|
||||
return _bridge
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_server: FastMCP) -> AsyncIterator[None]:
|
||||
"""Manage FreeCAD bridge lifecycle.
|
||||
|
||||
This async context manager initializes the FreeCAD bridge on startup
|
||||
and disconnects it on shutdown.
|
||||
|
||||
Args:
|
||||
_server: The FastMCP server instance (unused).
|
||||
|
||||
Yields:
|
||||
None - the bridge is stored in the global _bridge variable.
|
||||
"""
|
||||
global _bridge
|
||||
config = get_config()
|
||||
|
||||
logger.info("Initializing FreeCAD bridge...")
|
||||
|
||||
if config.mode == FreecadMode.EMBEDDED:
|
||||
from freecad_mcp.bridge.embedded import EmbeddedBridge
|
||||
|
||||
_bridge = EmbeddedBridge(
|
||||
freecad_path=str(config.freecad_path) if config.freecad_path else None,
|
||||
)
|
||||
logger.info("Using embedded bridge (headless mode)")
|
||||
|
||||
elif config.mode == FreecadMode.XMLRPC:
|
||||
from freecad_mcp.bridge.xmlrpc import XmlRpcBridge
|
||||
|
||||
_bridge = XmlRpcBridge(
|
||||
host=config.socket_host,
|
||||
port=config.xmlrpc_port,
|
||||
)
|
||||
logger.info(
|
||||
"Using XML-RPC bridge: %s:%d", config.socket_host, config.xmlrpc_port
|
||||
)
|
||||
|
||||
else: # SOCKET mode
|
||||
from freecad_mcp.bridge.socket import SocketBridge
|
||||
|
||||
_bridge = SocketBridge(
|
||||
host=config.socket_host,
|
||||
port=config.socket_port,
|
||||
)
|
||||
logger.info(
|
||||
"Using socket bridge: %s:%d", config.socket_host, config.socket_port
|
||||
)
|
||||
|
||||
await _bridge.connect()
|
||||
logger.info("FreeCAD bridge connected")
|
||||
|
||||
# Log FreeCAD version
|
||||
try:
|
||||
version = await _bridge.get_freecad_version()
|
||||
logger.info(
|
||||
"FreeCAD %s (GUI: %s)",
|
||||
version.get("version", "unknown"),
|
||||
"available" if version.get("gui_available") else "headless",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Could not get FreeCAD version: %s", e)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Shutdown
|
||||
if _bridge:
|
||||
logger.info("Disconnecting FreeCAD bridge...")
|
||||
await _bridge.disconnect()
|
||||
_bridge = None
|
||||
|
||||
|
||||
# Create the MCP server instance with lifespan
|
||||
mcp = FastMCP(
|
||||
name="freecad-mcp",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
def register_all_components() -> None:
|
||||
"""Register all MCP components (tools, resources, prompts)."""
|
||||
# Register tools
|
||||
from freecad_mcp.tools import register_all_tools
|
||||
|
||||
register_all_tools(mcp, get_bridge)
|
||||
|
||||
# Register resources
|
||||
from freecad_mcp.resources import register_resources
|
||||
|
||||
register_resources(mcp, get_bridge)
|
||||
|
||||
# Register prompts
|
||||
from freecad_mcp.prompts import register_prompts
|
||||
|
||||
register_prompts(mcp, get_bridge)
|
||||
|
||||
|
||||
# Register all components
|
||||
register_all_components()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the FreeCAD MCP server."""
|
||||
config = get_config()
|
||||
|
||||
# Set up logging
|
||||
logging.getLogger().setLevel(config.log_level)
|
||||
logger.info("Starting FreeCAD MCP server")
|
||||
logger.info("Mode: %s", config.mode.value)
|
||||
logger.info("Transport: %s", config.transport.value)
|
||||
|
||||
# Run the server
|
||||
if config.transport == TransportType.HTTP:
|
||||
logger.info("Starting HTTP transport on port %d", config.http_port)
|
||||
mcp.run( # type: ignore[call-arg]
|
||||
transport="streamable-http",
|
||||
host="0.0.0.0", # noqa: S104
|
||||
port=config.http_port,
|
||||
)
|
||||
else:
|
||||
logger.info("Starting stdio transport")
|
||||
mcp.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""MCP tool implementations for FreeCAD.
|
||||
|
||||
This package contains all MCP tool definitions for interacting with FreeCAD.
|
||||
Tools are organized by category:
|
||||
|
||||
- execution: Python code execution tools
|
||||
- documents: Document management tools
|
||||
- objects: Object creation and manipulation tools
|
||||
- partdesign: PartDesign workbench tools
|
||||
- export: Export functionality tools
|
||||
- macros: Macro management tools
|
||||
- view: View and screenshot tools
|
||||
"""
|
||||
|
||||
from freecad_mcp.tools.documents import register_document_tools
|
||||
from freecad_mcp.tools.execution import register_execution_tools
|
||||
from freecad_mcp.tools.export import register_export_tools
|
||||
from freecad_mcp.tools.macros import register_macro_tools
|
||||
from freecad_mcp.tools.objects import register_object_tools
|
||||
from freecad_mcp.tools.partdesign import register_partdesign_tools
|
||||
from freecad_mcp.tools.view import register_view_tools
|
||||
|
||||
__all__ = [
|
||||
"register_all_tools",
|
||||
"register_document_tools",
|
||||
"register_execution_tools",
|
||||
"register_export_tools",
|
||||
"register_macro_tools",
|
||||
"register_object_tools",
|
||||
"register_partdesign_tools",
|
||||
"register_view_tools",
|
||||
]
|
||||
|
||||
|
||||
def register_all_tools(mcp, get_bridge_func) -> None:
|
||||
"""Register all FreeCAD tools with the MCP server.
|
||||
|
||||
Args:
|
||||
mcp: The FastMCP server instance.
|
||||
get_bridge_func: Async function to get the active bridge.
|
||||
"""
|
||||
register_execution_tools(mcp, get_bridge_func)
|
||||
register_document_tools(mcp, get_bridge_func)
|
||||
register_object_tools(mcp, get_bridge_func)
|
||||
register_partdesign_tools(mcp, get_bridge_func)
|
||||
register_export_tools(mcp, get_bridge_func)
|
||||
register_macro_tools(mcp, get_bridge_func)
|
||||
register_view_tools(mcp, get_bridge_func)
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Document management tools for FreeCAD MCP server.
|
||||
|
||||
This module provides tools for managing FreeCAD documents:
|
||||
creating, opening, saving, closing, and listing documents.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def register_document_tools(mcp, get_bridge) -> None:
|
||||
"""Register document-related tools with the MCP server.
|
||||
|
||||
Args:
|
||||
mcp: The FastMCP server instance.
|
||||
get_bridge: Async function to get the active bridge.
|
||||
"""
|
||||
|
||||
@mcp.tool()
|
||||
async def list_documents() -> list[dict[str, Any]]:
|
||||
"""List all open FreeCAD documents.
|
||||
|
||||
Returns:
|
||||
List of dictionaries, each containing:
|
||||
- name: Internal document name
|
||||
- label: Display label
|
||||
- path: File path (None if not saved)
|
||||
- is_modified: Whether document has unsaved changes
|
||||
- object_count: Number of objects in document
|
||||
- active_object: Name of currently active object (None if none)
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
docs = await bridge.get_documents()
|
||||
return [
|
||||
{
|
||||
"name": doc.name,
|
||||
"label": doc.label,
|
||||
"path": doc.path,
|
||||
"is_modified": doc.is_modified,
|
||||
"object_count": len(doc.objects),
|
||||
"active_object": doc.active_object,
|
||||
}
|
||||
for doc in docs
|
||||
]
|
||||
|
||||
@mcp.tool()
|
||||
async def get_active_document() -> dict[str, Any] | None:
|
||||
"""Get the currently active FreeCAD document.
|
||||
|
||||
Returns:
|
||||
Dictionary with document information, or None if no active document:
|
||||
- name: Internal document name
|
||||
- label: Display label
|
||||
- path: File path (None if not saved)
|
||||
- is_modified: Whether document has unsaved changes
|
||||
- objects: List of object names
|
||||
- active_object: Name of currently active object (None if none)
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
doc = await bridge.get_active_document()
|
||||
if doc is None:
|
||||
return None
|
||||
return {
|
||||
"name": doc.name,
|
||||
"label": doc.label,
|
||||
"path": doc.path,
|
||||
"is_modified": doc.is_modified,
|
||||
"objects": doc.objects,
|
||||
"active_object": doc.active_object,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def create_document(
|
||||
name: str = "Unnamed",
|
||||
label: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new FreeCAD document.
|
||||
|
||||
Args:
|
||||
name: Internal document name (no spaces allowed, will be sanitized).
|
||||
label: Display label (can contain spaces). Defaults to name.
|
||||
|
||||
Returns:
|
||||
Dictionary with created document information:
|
||||
- name: Internal document name
|
||||
- label: Display label
|
||||
- path: File path (None for new document)
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
doc = await bridge.create_document(name, label)
|
||||
return {
|
||||
"name": doc.name,
|
||||
"label": doc.label,
|
||||
"path": doc.path,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def open_document(path: str) -> dict[str, Any]:
|
||||
"""Open an existing FreeCAD document from file.
|
||||
|
||||
Args:
|
||||
path: Full path to the .FCStd file to open.
|
||||
|
||||
Returns:
|
||||
Dictionary with opened document information:
|
||||
- name: Internal document name
|
||||
- label: Display label
|
||||
- path: File path
|
||||
- objects: List of object names
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the file doesn't exist.
|
||||
ValueError: If the file is not a valid FreeCAD document.
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
doc = await bridge.open_document(path)
|
||||
return {
|
||||
"name": doc.name,
|
||||
"label": doc.label,
|
||||
"path": doc.path,
|
||||
"objects": doc.objects,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def save_document(
|
||||
doc_name: str | None = None,
|
||||
path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Save a FreeCAD document.
|
||||
|
||||
Args:
|
||||
doc_name: Name of document to save. Uses active document if None.
|
||||
path: File path to save to. Uses existing path if None.
|
||||
Required for new (unsaved) documents.
|
||||
|
||||
Returns:
|
||||
Dictionary with save result:
|
||||
- success: Whether save was successful
|
||||
- path: Path where document was saved
|
||||
|
||||
Raises:
|
||||
ValueError: If document not found or no path specified for new doc.
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
saved_path = await bridge.save_document(doc_name, path)
|
||||
return {
|
||||
"success": True,
|
||||
"path": saved_path,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def close_document(
|
||||
doc_name: str | None = None,
|
||||
save_changes: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Close a FreeCAD document.
|
||||
|
||||
Args:
|
||||
doc_name: Name of document to close. Uses active document if None.
|
||||
save_changes: Whether to save changes before closing. Defaults to False.
|
||||
|
||||
Returns:
|
||||
Dictionary with close result:
|
||||
- success: Whether close was successful
|
||||
- saved: Whether document was saved before closing
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
saved = False
|
||||
if save_changes:
|
||||
try:
|
||||
await bridge.save_document(doc_name)
|
||||
saved = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await bridge.close_document(doc_name)
|
||||
return {
|
||||
"success": True,
|
||||
"saved": saved,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def recompute_document(doc_name: str | None = None) -> dict[str, Any]:
|
||||
"""Recompute a FreeCAD document to update all dependent objects.
|
||||
|
||||
Args:
|
||||
doc_name: Name of document to recompute. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with recompute result:
|
||||
- success: Whether recompute was successful
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
code = f"""
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
raise ValueError("No document found")
|
||||
doc.recompute()
|
||||
_result_ = True
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
return {
|
||||
"success": result.success,
|
||||
"error": result.error_traceback if not result.success else None,
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Execution tools for FreeCAD MCP server.
|
||||
|
||||
This module provides tools for executing Python code in FreeCAD's context,
|
||||
getting version information, and accessing the console.
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def register_execution_tools(mcp, get_bridge) -> None:
|
||||
"""Register execution-related tools with the MCP server.
|
||||
|
||||
Args:
|
||||
mcp: The FastMCP server instance.
|
||||
get_bridge: Async function to get the active bridge.
|
||||
"""
|
||||
|
||||
@mcp.tool()
|
||||
async def execute_python(
|
||||
code: str,
|
||||
timeout_ms: int = 30000,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute Python code in FreeCAD's Python console context.
|
||||
|
||||
This tool allows you to run arbitrary Python code within FreeCAD's
|
||||
environment, with access to all FreeCAD modules and the active document.
|
||||
|
||||
Args:
|
||||
code: Python code to execute. Use `_result_ = value` to return data
|
||||
to the caller. The code has access to FreeCAD, App, FreeCADGui,
|
||||
and Gui modules.
|
||||
timeout_ms: Maximum execution time in milliseconds. Defaults to 30000.
|
||||
|
||||
Returns:
|
||||
Dictionary containing execution results:
|
||||
- success: Whether execution completed without errors
|
||||
- result: The value assigned to `_result_` variable
|
||||
- stdout: Captured standard output
|
||||
- stderr: Captured standard error
|
||||
- execution_time_ms: Time taken in milliseconds
|
||||
- error_type: Type of exception if failed (None if success)
|
||||
- error_traceback: Full traceback if failed (None if success)
|
||||
|
||||
Example:
|
||||
Create a simple box and return its volume::
|
||||
|
||||
execute_python('''
|
||||
import Part
|
||||
box = Part.makeBox(10, 20, 30)
|
||||
_result_ = {"volume": box.Volume, "area": box.Area}
|
||||
''')
|
||||
|
||||
List all objects in the active document::
|
||||
|
||||
execute_python('''
|
||||
doc = FreeCAD.ActiveDocument
|
||||
if doc:
|
||||
_result_ = [obj.Name for obj in doc.Objects]
|
||||
else:
|
||||
_result_ = []
|
||||
''')
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
result = await bridge.execute_python(code, timeout_ms)
|
||||
return {
|
||||
"success": result.success,
|
||||
"result": result.result,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"execution_time_ms": result.execution_time_ms,
|
||||
"error_type": result.error_type,
|
||||
"error_traceback": result.error_traceback,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def get_freecad_version() -> dict[str, Any]:
|
||||
"""Get FreeCAD version and build information.
|
||||
|
||||
Returns:
|
||||
Dictionary containing version information:
|
||||
- version: Version string (e.g., "0.21.2")
|
||||
- version_tuple: Version as list of integers
|
||||
- build_date: Build date string
|
||||
- python_version: Embedded Python version
|
||||
- gui_available: Whether GUI is available
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
return await bridge.get_freecad_version()
|
||||
|
||||
@mcp.tool()
|
||||
async def get_connection_status() -> dict[str, Any]:
|
||||
"""Get the current FreeCAD connection status.
|
||||
|
||||
Returns:
|
||||
Dictionary containing connection information:
|
||||
- connected: Whether bridge is connected
|
||||
- mode: Connection mode (embedded, xmlrpc, socket)
|
||||
- freecad_version: FreeCAD version string
|
||||
- gui_available: Whether GUI is available
|
||||
- last_ping_ms: Last ping latency in milliseconds
|
||||
- error: Error message if not connected
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
status = await bridge.get_status()
|
||||
return {
|
||||
"connected": status.connected,
|
||||
"mode": status.mode,
|
||||
"freecad_version": status.freecad_version,
|
||||
"gui_available": status.gui_available,
|
||||
"last_ping_ms": status.last_ping_ms,
|
||||
"error": status.error,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def get_console_output(lines: int = 100) -> list[str]:
|
||||
"""Get recent FreeCAD console output.
|
||||
|
||||
Args:
|
||||
lines: Maximum number of lines to return. Defaults to 100.
|
||||
|
||||
Returns:
|
||||
List of console output lines, most recent last.
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
return await bridge.get_console_output(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def get_mcp_server_environment() -> dict[str, Any]:
|
||||
"""Get environment information about the MCP server process.
|
||||
|
||||
This tool returns information about the environment where the MCP server
|
||||
is running, which is useful for debugging and verifying which MCP server
|
||||
instance you are connected to (e.g., host vs Docker container).
|
||||
|
||||
Returns:
|
||||
Dictionary containing environment information:
|
||||
- hostname: Machine hostname
|
||||
- os_name: Operating system name (Linux, Darwin, Windows)
|
||||
- os_version: Operating system version
|
||||
- platform: Platform identifier string
|
||||
- python_version: Python version running the MCP server
|
||||
- in_docker: Whether running inside a Docker container
|
||||
- docker_container_id: Container ID if in Docker (first 12 chars)
|
||||
- env_vars: Selected environment variables for debugging:
|
||||
- FREECAD_MODE: Connection mode
|
||||
- FREECAD_SOCKET_HOST: Socket host
|
||||
- FREECAD_SOCKET_PORT: Socket port
|
||||
- FREECAD_XMLRPC_PORT: XML-RPC port
|
||||
|
||||
Example:
|
||||
Verify you're talking to the containerized MCP server::
|
||||
|
||||
env = get_mcp_server_environment()
|
||||
if env["in_docker"]:
|
||||
print(f"Connected to container: {env['docker_container_id']}")
|
||||
else:
|
||||
print("Connected to host MCP server")
|
||||
"""
|
||||
|
||||
def _detect_docker() -> tuple[bool, str | None]:
|
||||
"""Detect if running inside Docker and get container ID."""
|
||||
# Check for .dockerenv file (most reliable)
|
||||
dockerenv = Path("/.dockerenv")
|
||||
if dockerenv.exists():
|
||||
# Try to get container ID from cgroup
|
||||
container_id = None
|
||||
try:
|
||||
cgroup_path = Path("/proc/self/cgroup")
|
||||
with cgroup_path.open() as f:
|
||||
for line in f:
|
||||
if "docker" in line or "containerd" in line:
|
||||
# Extract container ID from path
|
||||
parts = line.strip().split("/")
|
||||
if parts:
|
||||
cid = parts[-1]
|
||||
# Container IDs are 64 hex chars
|
||||
if len(cid) >= 12:
|
||||
container_id = cid[:12]
|
||||
break
|
||||
except (OSError, IndexError):
|
||||
pass
|
||||
return True, container_id
|
||||
|
||||
# Also check cgroup for containerized environments
|
||||
try:
|
||||
cgroup_init = Path("/proc/1/cgroup")
|
||||
with cgroup_init.open() as f:
|
||||
content = f.read()
|
||||
if "docker" in content or "containerd" in content:
|
||||
return True, None
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return False, None
|
||||
|
||||
in_docker, container_id = _detect_docker()
|
||||
|
||||
return {
|
||||
"hostname": socket.gethostname(),
|
||||
"os_name": platform.system(),
|
||||
"os_version": platform.release(),
|
||||
"platform": platform.platform(),
|
||||
"python_version": platform.python_version(),
|
||||
"in_docker": in_docker,
|
||||
"docker_container_id": container_id,
|
||||
"env_vars": {
|
||||
"FREECAD_MODE": os.environ.get("FREECAD_MODE", ""),
|
||||
"FREECAD_SOCKET_HOST": os.environ.get("FREECAD_SOCKET_HOST", ""),
|
||||
"FREECAD_SOCKET_PORT": os.environ.get("FREECAD_SOCKET_PORT", ""),
|
||||
"FREECAD_XMLRPC_PORT": os.environ.get("FREECAD_XMLRPC_PORT", ""),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
"""Export tools for FreeCAD MCP server.
|
||||
|
||||
This module provides tools for exporting FreeCAD documents and objects
|
||||
to various file formats: STEP, STL, 3MF, OBJ, IGES, and FreeCAD native.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def register_export_tools(mcp, get_bridge) -> None:
|
||||
"""Register export-related tools with the MCP server.
|
||||
|
||||
Args:
|
||||
mcp: The FastMCP server instance.
|
||||
get_bridge: Async function to get the active bridge.
|
||||
"""
|
||||
|
||||
@mcp.tool()
|
||||
async def export_step(
|
||||
file_path: str,
|
||||
object_names: list[str] | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Export objects to STEP format.
|
||||
|
||||
STEP (Standard for the Exchange of Product Data) is an ISO standard
|
||||
for CAD data exchange, widely supported by CAD software.
|
||||
|
||||
Args:
|
||||
file_path: Path for the output .step file.
|
||||
object_names: List of object names to export. Exports all visible if None.
|
||||
doc_name: Document to export from. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with export result:
|
||||
- success: Whether export was successful
|
||||
- path: Path to exported file
|
||||
- object_count: Number of objects exported
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
objects_filter = (
|
||||
f"[doc.getObject(n) for n in {object_names!r}]"
|
||||
if object_names
|
||||
else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]"
|
||||
)
|
||||
|
||||
code = f"""
|
||||
import Part
|
||||
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
raise ValueError("No document found")
|
||||
|
||||
objects = {objects_filter}
|
||||
objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')]
|
||||
|
||||
if not objects:
|
||||
raise ValueError("No exportable objects found")
|
||||
|
||||
# Combine shapes
|
||||
if len(objects) == 1:
|
||||
shape = objects[0].Shape
|
||||
else:
|
||||
shape = Part.makeCompound([obj.Shape for obj in objects])
|
||||
|
||||
shape.exportStep({file_path!r})
|
||||
|
||||
_result_ = {{
|
||||
"success": True,
|
||||
"path": {file_path!r},
|
||||
"object_count": len(objects),
|
||||
}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success:
|
||||
return result.result
|
||||
raise ValueError(result.error_traceback or "STEP export failed")
|
||||
|
||||
@mcp.tool()
|
||||
async def export_stl(
|
||||
file_path: str,
|
||||
object_names: list[str] | None = None,
|
||||
doc_name: str | None = None,
|
||||
mesh_tolerance: float = 0.1,
|
||||
) -> dict[str, Any]:
|
||||
"""Export objects to STL format.
|
||||
|
||||
STL (Stereolithography) is commonly used for 3D printing and
|
||||
rapid prototyping. It represents surfaces as triangular meshes.
|
||||
|
||||
Args:
|
||||
file_path: Path for the output .stl file.
|
||||
object_names: List of object names to export. Exports all visible if None.
|
||||
doc_name: Document to export from. Uses active document if None.
|
||||
mesh_tolerance: Mesh approximation tolerance. Lower = finer mesh.
|
||||
|
||||
Returns:
|
||||
Dictionary with export result:
|
||||
- success: Whether export was successful
|
||||
- path: Path to exported file
|
||||
- object_count: Number of objects exported
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
objects_filter = (
|
||||
f"[doc.getObject(n) for n in {object_names!r}]"
|
||||
if object_names
|
||||
else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]"
|
||||
)
|
||||
|
||||
code = f"""
|
||||
import Mesh
|
||||
import Part
|
||||
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
raise ValueError("No document found")
|
||||
|
||||
objects = {objects_filter}
|
||||
objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')]
|
||||
|
||||
if not objects:
|
||||
raise ValueError("No exportable objects found")
|
||||
|
||||
# Create mesh from shapes
|
||||
meshes = []
|
||||
for obj in objects:
|
||||
mesh = Mesh.Mesh()
|
||||
mesh.addFacets(obj.Shape.tessellate({mesh_tolerance})[0])
|
||||
meshes.append(mesh)
|
||||
|
||||
# Combine meshes
|
||||
if len(meshes) == 1:
|
||||
final_mesh = meshes[0]
|
||||
else:
|
||||
final_mesh = Mesh.Mesh()
|
||||
for m in meshes:
|
||||
final_mesh.addMesh(m)
|
||||
|
||||
final_mesh.write({file_path!r})
|
||||
|
||||
_result_ = {{
|
||||
"success": True,
|
||||
"path": {file_path!r},
|
||||
"object_count": len(objects),
|
||||
}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success:
|
||||
return result.result
|
||||
raise ValueError(result.error_traceback or "STL export failed")
|
||||
|
||||
@mcp.tool()
|
||||
async def export_3mf(
|
||||
file_path: str,
|
||||
object_names: list[str] | None = None,
|
||||
doc_name: str | None = None,
|
||||
mesh_tolerance: float = 0.1,
|
||||
) -> dict[str, Any]:
|
||||
"""Export objects to 3MF format.
|
||||
|
||||
3MF (3D Manufacturing Format) is a modern 3D printing format that
|
||||
supports richer data than STL, including colors, materials, and
|
||||
print settings. It is increasingly preferred over STL for 3D printing.
|
||||
|
||||
Args:
|
||||
file_path: Path for the output .3mf file.
|
||||
object_names: List of object names to export. Exports all visible if None.
|
||||
doc_name: Document to export from. Uses active document if None.
|
||||
mesh_tolerance: Mesh approximation tolerance. Lower = finer mesh.
|
||||
|
||||
Returns:
|
||||
Dictionary with export result:
|
||||
- success: Whether export was successful
|
||||
- path: Path to exported file
|
||||
- object_count: Number of objects exported
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
objects_filter = (
|
||||
f"[doc.getObject(n) for n in {object_names!r}]"
|
||||
if object_names
|
||||
else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]"
|
||||
)
|
||||
|
||||
code = f"""
|
||||
import Mesh
|
||||
import Part
|
||||
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
raise ValueError("No document found")
|
||||
|
||||
objects = {objects_filter}
|
||||
objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')]
|
||||
|
||||
if not objects:
|
||||
raise ValueError("No exportable objects found")
|
||||
|
||||
# Create mesh from shapes
|
||||
meshes = []
|
||||
for obj in objects:
|
||||
mesh = Mesh.Mesh()
|
||||
mesh.addFacets(obj.Shape.tessellate({mesh_tolerance})[0])
|
||||
meshes.append(mesh)
|
||||
|
||||
# Combine meshes
|
||||
if len(meshes) == 1:
|
||||
final_mesh = meshes[0]
|
||||
else:
|
||||
final_mesh = Mesh.Mesh()
|
||||
for m in meshes:
|
||||
final_mesh.addMesh(m)
|
||||
|
||||
# Export to 3MF format
|
||||
final_mesh.write({file_path!r})
|
||||
|
||||
_result_ = {{
|
||||
"success": True,
|
||||
"path": {file_path!r},
|
||||
"object_count": len(objects),
|
||||
}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success:
|
||||
return result.result
|
||||
raise ValueError(result.error_traceback or "3MF export failed")
|
||||
|
||||
@mcp.tool()
|
||||
async def export_obj(
|
||||
file_path: str,
|
||||
object_names: list[str] | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Export objects to OBJ format.
|
||||
|
||||
OBJ (Wavefront) is a common 3D model format supported by many
|
||||
3D graphics applications and game engines.
|
||||
|
||||
Args:
|
||||
file_path: Path for the output .obj file.
|
||||
object_names: List of object names to export. Exports all visible if None.
|
||||
doc_name: Document to export from. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with export result:
|
||||
- success: Whether export was successful
|
||||
- path: Path to exported file
|
||||
- object_count: Number of objects exported
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
objects_filter = (
|
||||
f"[doc.getObject(n) for n in {object_names!r}]"
|
||||
if object_names
|
||||
else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]"
|
||||
)
|
||||
|
||||
code = f"""
|
||||
import Mesh
|
||||
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
raise ValueError("No document found")
|
||||
|
||||
objects = {objects_filter}
|
||||
objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')]
|
||||
|
||||
if not objects:
|
||||
raise ValueError("No exportable objects found")
|
||||
|
||||
# Create mesh from shapes
|
||||
meshes = []
|
||||
for obj in objects:
|
||||
mesh = Mesh.Mesh()
|
||||
mesh.addFacets(obj.Shape.tessellate(0.1)[0])
|
||||
meshes.append(mesh)
|
||||
|
||||
# Combine meshes
|
||||
if len(meshes) == 1:
|
||||
final_mesh = meshes[0]
|
||||
else:
|
||||
final_mesh = Mesh.Mesh()
|
||||
for m in meshes:
|
||||
final_mesh.addMesh(m)
|
||||
|
||||
final_mesh.write({file_path!r})
|
||||
|
||||
_result_ = {{
|
||||
"success": True,
|
||||
"path": {file_path!r},
|
||||
"object_count": len(objects),
|
||||
}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success:
|
||||
return result.result
|
||||
raise ValueError(result.error_traceback or "OBJ export failed")
|
||||
|
||||
@mcp.tool()
|
||||
async def export_iges(
|
||||
file_path: str,
|
||||
object_names: list[str] | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Export objects to IGES format.
|
||||
|
||||
IGES (Initial Graphics Exchange Specification) is an older but still
|
||||
widely supported CAD data exchange format.
|
||||
|
||||
Args:
|
||||
file_path: Path for the output .iges file.
|
||||
object_names: List of object names to export. Exports all visible if None.
|
||||
doc_name: Document to export from. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with export result:
|
||||
- success: Whether export was successful
|
||||
- path: Path to exported file
|
||||
- object_count: Number of objects exported
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
objects_filter = (
|
||||
f"[doc.getObject(n) for n in {object_names!r}]"
|
||||
if object_names
|
||||
else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]"
|
||||
)
|
||||
|
||||
code = f"""
|
||||
import Part
|
||||
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
raise ValueError("No document found")
|
||||
|
||||
objects = {objects_filter}
|
||||
objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')]
|
||||
|
||||
if not objects:
|
||||
raise ValueError("No exportable objects found")
|
||||
|
||||
# Combine shapes
|
||||
if len(objects) == 1:
|
||||
shape = objects[0].Shape
|
||||
else:
|
||||
shape = Part.makeCompound([obj.Shape for obj in objects])
|
||||
|
||||
shape.exportIges({file_path!r})
|
||||
|
||||
_result_ = {{
|
||||
"success": True,
|
||||
"path": {file_path!r},
|
||||
"object_count": len(objects),
|
||||
}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success:
|
||||
return result.result
|
||||
raise ValueError(result.error_traceback or "IGES export failed")
|
||||
|
||||
@mcp.tool()
|
||||
async def import_step(
|
||||
file_path: str,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Import a STEP file into FreeCAD.
|
||||
|
||||
Args:
|
||||
file_path: Path to the .step file to import.
|
||||
doc_name: Document to import into. Creates new if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with import result:
|
||||
- success: Whether import was successful
|
||||
- document: Name of document containing imported objects
|
||||
- objects: List of imported object names
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
code = f"""
|
||||
import Part
|
||||
import os
|
||||
|
||||
if not os.path.exists({file_path!r}):
|
||||
raise FileNotFoundError(f"File not found: {file_path!r}")
|
||||
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
doc = FreeCAD.newDocument("Imported")
|
||||
|
||||
# Get object count before import
|
||||
before_count = len(doc.Objects)
|
||||
|
||||
Part.insert({file_path!r}, doc.Name)
|
||||
doc.recompute()
|
||||
|
||||
# Get new objects
|
||||
new_objects = [obj.Name for obj in doc.Objects[before_count:]]
|
||||
|
||||
_result_ = {{
|
||||
"success": True,
|
||||
"document": doc.Name,
|
||||
"objects": new_objects,
|
||||
}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success:
|
||||
return result.result
|
||||
raise ValueError(result.error_traceback or "STEP import failed")
|
||||
|
||||
@mcp.tool()
|
||||
async def import_stl(
|
||||
file_path: str,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Import an STL file into FreeCAD.
|
||||
|
||||
Args:
|
||||
file_path: Path to the .stl file to import.
|
||||
doc_name: Document to import into. Creates new if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with import result:
|
||||
- success: Whether import was successful
|
||||
- document: Name of document containing imported object
|
||||
- object: Name of imported mesh object
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
code = f"""
|
||||
import Mesh
|
||||
import os
|
||||
|
||||
if not os.path.exists({file_path!r}):
|
||||
raise FileNotFoundError(f"File not found: {file_path!r}")
|
||||
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
doc = FreeCAD.newDocument("Imported")
|
||||
|
||||
Mesh.insert({file_path!r}, doc.Name)
|
||||
doc.recompute()
|
||||
|
||||
# Get the last added object (the imported mesh)
|
||||
mesh_obj = doc.Objects[-1]
|
||||
|
||||
_result_ = {{
|
||||
"success": True,
|
||||
"document": doc.Name,
|
||||
"object": mesh_obj.Name,
|
||||
}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success:
|
||||
return result.result
|
||||
raise ValueError(result.error_traceback or "STL import failed")
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Macro management tools for FreeCAD MCP server.
|
||||
|
||||
This module provides tools for managing FreeCAD macros:
|
||||
listing, running, creating, and editing macros.
|
||||
|
||||
Based on learnings from ATOI-Ming/FreeCAD-MCP which has a
|
||||
macro-centric workflow with templates and validation.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def register_macro_tools(mcp, get_bridge) -> None:
|
||||
"""Register macro-related tools with the MCP server.
|
||||
|
||||
Args:
|
||||
mcp: The FastMCP server instance.
|
||||
get_bridge: Async function to get the active bridge.
|
||||
"""
|
||||
|
||||
@mcp.tool()
|
||||
async def list_macros() -> list[dict[str, Any]]:
|
||||
"""List all available FreeCAD macros.
|
||||
|
||||
Returns:
|
||||
List of dictionaries, each containing:
|
||||
- name: Macro name (without extension)
|
||||
- path: Full path to macro file
|
||||
- description: Macro description from comments
|
||||
- is_system: Whether it's a system macro
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
macros = await bridge.get_macros()
|
||||
return [
|
||||
{
|
||||
"name": macro.name,
|
||||
"path": macro.path,
|
||||
"description": macro.description,
|
||||
"is_system": macro.is_system,
|
||||
}
|
||||
for macro in macros
|
||||
]
|
||||
|
||||
@mcp.tool()
|
||||
async def run_macro(
|
||||
macro_name: str,
|
||||
args: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a FreeCAD macro by name.
|
||||
|
||||
Args:
|
||||
macro_name: Name of the macro to run (without .FCMacro extension).
|
||||
args: Optional dictionary of arguments to pass to the macro.
|
||||
These will be set as variables before execution.
|
||||
|
||||
Returns:
|
||||
Dictionary with execution result:
|
||||
- success: Whether macro executed successfully
|
||||
- stdout: Captured standard output
|
||||
- stderr: Captured standard error
|
||||
- execution_time_ms: Execution time in milliseconds
|
||||
- error_type: Error type if failed
|
||||
- error_traceback: Full traceback if failed
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
result = await bridge.run_macro(macro_name, args)
|
||||
return {
|
||||
"success": result.success,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"execution_time_ms": result.execution_time_ms,
|
||||
"error_type": result.error_type,
|
||||
"error_traceback": result.error_traceback,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def create_macro(
|
||||
name: str,
|
||||
code: str,
|
||||
description: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new FreeCAD macro.
|
||||
|
||||
The macro will be created in the user's macro directory with
|
||||
standard FreeCAD imports automatically added.
|
||||
|
||||
Args:
|
||||
name: Macro name (without .FCMacro extension).
|
||||
code: Python code for the macro body.
|
||||
description: Optional description for the macro.
|
||||
|
||||
Returns:
|
||||
Dictionary with created macro information:
|
||||
- name: Macro name
|
||||
- path: Full path to created macro file
|
||||
- description: Macro description
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
macro = await bridge.create_macro(name, code, description)
|
||||
return {
|
||||
"name": macro.name,
|
||||
"path": macro.path,
|
||||
"description": macro.description,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def read_macro(macro_name: str) -> dict[str, Any]:
|
||||
"""Read the contents of a FreeCAD macro.
|
||||
|
||||
Args:
|
||||
macro_name: Name of the macro to read (without .FCMacro extension).
|
||||
|
||||
Returns:
|
||||
Dictionary with macro contents:
|
||||
- name: Macro name
|
||||
- code: Full macro code
|
||||
- path: Path to macro file
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
code = f"""
|
||||
import os
|
||||
|
||||
# Find macro file
|
||||
macro_name = {macro_name!r}
|
||||
macro_file = None
|
||||
|
||||
user_path = FreeCAD.getUserMacroDir(True)
|
||||
user_macro = os.path.join(user_path, macro_name + ".FCMacro")
|
||||
if os.path.exists(user_macro):
|
||||
macro_file = user_macro
|
||||
|
||||
if not macro_file:
|
||||
system_path = FreeCAD.getResourceDir() + "Macro"
|
||||
system_macro = os.path.join(system_path, macro_name + ".FCMacro")
|
||||
if os.path.exists(system_macro):
|
||||
macro_file = system_macro
|
||||
|
||||
if not macro_file:
|
||||
raise FileNotFoundError(f"Macro not found: {{macro_name}}")
|
||||
|
||||
with open(macro_file, "r") as f:
|
||||
macro_code = f.read()
|
||||
|
||||
_result_ = {{
|
||||
"name": macro_name,
|
||||
"code": macro_code,
|
||||
"path": macro_file,
|
||||
}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success:
|
||||
return result.result
|
||||
raise ValueError(result.error_traceback or "Read macro failed")
|
||||
|
||||
@mcp.tool()
|
||||
async def delete_macro(macro_name: str) -> dict[str, Any]:
|
||||
"""Delete a user macro.
|
||||
|
||||
Note: Only user macros can be deleted. System macros are protected.
|
||||
|
||||
Args:
|
||||
macro_name: Name of the macro to delete (without .FCMacro extension).
|
||||
|
||||
Returns:
|
||||
Dictionary with delete result:
|
||||
- success: Whether deletion was successful
|
||||
- path: Path of deleted macro
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
code = f"""
|
||||
import os
|
||||
|
||||
macro_name = {macro_name!r}
|
||||
user_path = FreeCAD.getUserMacroDir(True)
|
||||
macro_file = os.path.join(user_path, macro_name + ".FCMacro")
|
||||
|
||||
if not os.path.exists(macro_file):
|
||||
raise FileNotFoundError(f"User macro not found: {{macro_name}}")
|
||||
|
||||
os.remove(macro_file)
|
||||
|
||||
_result_ = {{
|
||||
"success": True,
|
||||
"path": macro_file,
|
||||
}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success:
|
||||
return result.result
|
||||
raise ValueError(result.error_traceback or "Delete macro failed")
|
||||
|
||||
@mcp.tool()
|
||||
async def create_macro_from_template(
|
||||
name: str,
|
||||
template: str = "basic",
|
||||
description: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new macro from a predefined template.
|
||||
|
||||
Available templates provide common starting points for FreeCAD macros.
|
||||
|
||||
Args:
|
||||
name: Macro name (without .FCMacro extension).
|
||||
template: Template name. Options:
|
||||
- "basic" - Basic template with imports
|
||||
- "part" - Part workbench operations template
|
||||
- "sketch" - Sketcher operations template
|
||||
- "gui" - GUI/dialog template
|
||||
- "selection" - Selection handling template
|
||||
description: Optional description for the macro.
|
||||
|
||||
Returns:
|
||||
Dictionary with created macro information:
|
||||
- name: Macro name
|
||||
- path: Full path to created macro file
|
||||
- template: Template used
|
||||
"""
|
||||
templates = {
|
||||
"basic": """
|
||||
# Your macro code goes here
|
||||
doc = FreeCAD.ActiveDocument
|
||||
if doc is None:
|
||||
doc = FreeCAD.newDocument("MacroDoc")
|
||||
|
||||
# Add your operations here
|
||||
print("Macro executed successfully!")
|
||||
""",
|
||||
"part": """
|
||||
import Part
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
if doc is None:
|
||||
doc = FreeCAD.newDocument("MacroDoc")
|
||||
|
||||
# Create a box as example
|
||||
box = doc.addObject("Part::Box", "MyBox")
|
||||
box.Length = 10
|
||||
box.Width = 20
|
||||
box.Height = 30
|
||||
|
||||
doc.recompute()
|
||||
print(f"Created box with volume: {box.Shape.Volume}")
|
||||
""",
|
||||
"sketch": """
|
||||
import Part
|
||||
import Sketcher
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
if doc is None:
|
||||
doc = FreeCAD.newDocument("MacroDoc")
|
||||
|
||||
# Create a sketch
|
||||
sketch = doc.addObject("Sketcher::SketchObject", "MySketch")
|
||||
|
||||
# Add a rectangle
|
||||
sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(0, 0, 0), FreeCAD.Vector(10, 0, 0)), False)
|
||||
sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(10, 0, 0), FreeCAD.Vector(10, 10, 0)), False)
|
||||
sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(10, 10, 0), FreeCAD.Vector(0, 10, 0)), False)
|
||||
sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(0, 10, 0), FreeCAD.Vector(0, 0, 0)), False)
|
||||
|
||||
doc.recompute()
|
||||
print("Created sketch with rectangle")
|
||||
""",
|
||||
"gui": """
|
||||
from PySide2 import QtWidgets
|
||||
|
||||
class MacroDialog(QtWidgets.QDialog):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Macro Dialog")
|
||||
self.setup_ui()
|
||||
|
||||
def setup_ui(self):
|
||||
layout = QtWidgets.QVBoxLayout()
|
||||
|
||||
self.label = QtWidgets.QLabel("Enter value:")
|
||||
layout.addWidget(self.label)
|
||||
|
||||
self.input = QtWidgets.QLineEdit()
|
||||
layout.addWidget(self.input)
|
||||
|
||||
buttons = QtWidgets.QDialogButtonBox(
|
||||
QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
# Show dialog
|
||||
dialog = MacroDialog()
|
||||
if dialog.exec_():
|
||||
value = dialog.input.text()
|
||||
print(f"User entered: {value}")
|
||||
""",
|
||||
"selection": """
|
||||
# Get current selection
|
||||
sel = FreeCADGui.Selection.getSelection()
|
||||
|
||||
if not sel:
|
||||
print("Nothing selected!")
|
||||
else:
|
||||
for obj in sel:
|
||||
print(f"Selected: {obj.Name} ({obj.TypeId})")
|
||||
|
||||
# Access shape if available
|
||||
if hasattr(obj, "Shape"):
|
||||
shape = obj.Shape
|
||||
print(f" - Volume: {shape.Volume}")
|
||||
print(f" - Area: {shape.Area}")
|
||||
print(f" - Faces: {len(shape.Faces)}")
|
||||
print(f" - Edges: {len(shape.Edges)}")
|
||||
""",
|
||||
}
|
||||
|
||||
if template not in templates:
|
||||
raise ValueError(
|
||||
f"Unknown template: {template}. Available: {list(templates.keys())}"
|
||||
)
|
||||
|
||||
bridge = await get_bridge()
|
||||
macro = await bridge.create_macro(name, templates[template], description)
|
||||
return {
|
||||
"name": macro.name,
|
||||
"path": macro.path,
|
||||
"template": template,
|
||||
}
|
||||
@@ -0,0 +1,993 @@
|
||||
"""Object management tools for FreeCAD MCP server.
|
||||
|
||||
This module provides tools for managing FreeCAD objects:
|
||||
creating, editing, deleting, and inspecting objects.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def register_object_tools(mcp, get_bridge) -> None:
|
||||
"""Register object-related tools with the MCP server.
|
||||
|
||||
Args:
|
||||
mcp: The FastMCP server instance.
|
||||
get_bridge: Async function to get the active bridge.
|
||||
"""
|
||||
|
||||
@mcp.tool()
|
||||
async def list_objects(doc_name: str | None = None) -> list[dict[str, Any]]:
|
||||
"""List all objects in a FreeCAD document.
|
||||
|
||||
Args:
|
||||
doc_name: Name of document. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
List of dictionaries, each containing:
|
||||
- name: Object name
|
||||
- label: Display label
|
||||
- type_id: FreeCAD type identifier (e.g., "Part::Box")
|
||||
- visibility: Whether object is visible
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
objects = await bridge.get_objects(doc_name)
|
||||
return [
|
||||
{
|
||||
"name": obj.name,
|
||||
"label": obj.label,
|
||||
"type_id": obj.type_id,
|
||||
"visibility": obj.visibility,
|
||||
}
|
||||
for obj in objects
|
||||
]
|
||||
|
||||
@mcp.tool()
|
||||
async def inspect_object(
|
||||
object_name: str,
|
||||
doc_name: str | None = None,
|
||||
include_properties: bool = True,
|
||||
include_shape: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Get detailed information about a FreeCAD object.
|
||||
|
||||
Args:
|
||||
object_name: Name of the object to inspect.
|
||||
doc_name: Document containing the object. Uses active document if None.
|
||||
include_properties: Whether to include property values.
|
||||
include_shape: Whether to include shape geometry details.
|
||||
|
||||
Returns:
|
||||
Dictionary containing comprehensive object information:
|
||||
- name: Object name
|
||||
- label: Object label
|
||||
- type_id: FreeCAD type identifier
|
||||
- properties: Dictionary of property names and values (if requested)
|
||||
- shape_info: Shape details (if requested and object has shape)
|
||||
- children: List of child object names
|
||||
- parents: List of parent object names
|
||||
- visibility: Whether object is visible
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
obj = await bridge.get_object(object_name, doc_name)
|
||||
|
||||
result = {
|
||||
"name": obj.name,
|
||||
"label": obj.label,
|
||||
"type_id": obj.type_id,
|
||||
"children": obj.children,
|
||||
"parents": obj.parents,
|
||||
"visibility": obj.visibility,
|
||||
}
|
||||
|
||||
if include_properties:
|
||||
result["properties"] = obj.properties
|
||||
|
||||
if include_shape and obj.shape_info:
|
||||
result["shape_info"] = obj.shape_info
|
||||
|
||||
return result
|
||||
|
||||
@mcp.tool()
|
||||
async def create_object(
|
||||
type_id: str,
|
||||
name: str | None = None,
|
||||
properties: dict[str, Any] | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new FreeCAD object.
|
||||
|
||||
Args:
|
||||
type_id: FreeCAD type ID for the object. Common types include:
|
||||
- "Part::Box" - Parametric box
|
||||
- "Part::Cylinder" - Parametric cylinder
|
||||
- "Part::Sphere" - Parametric sphere
|
||||
- "Part::Cone" - Parametric cone
|
||||
- "Part::Torus" - Parametric torus
|
||||
- "Part::Feature" - Generic Part feature
|
||||
- "Sketcher::SketchObject" - Sketch
|
||||
- "PartDesign::Body" - PartDesign body
|
||||
name: Object name. Auto-generated if None.
|
||||
properties: Initial property values to set.
|
||||
doc_name: Target document. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with created object information:
|
||||
- name: Object name
|
||||
- label: Object label
|
||||
- type_id: Object type
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
obj = await bridge.create_object(type_id, name, properties, doc_name)
|
||||
return {
|
||||
"name": obj.name,
|
||||
"label": obj.label,
|
||||
"type_id": obj.type_id,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def create_box(
|
||||
length: float = 10.0,
|
||||
width: float = 10.0,
|
||||
height: float = 10.0,
|
||||
name: str | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a Part Box primitive.
|
||||
|
||||
Args:
|
||||
length: Box length (X dimension). Defaults to 10.0.
|
||||
width: Box width (Y dimension). Defaults to 10.0.
|
||||
height: Box height (Z dimension). Defaults to 10.0.
|
||||
name: Object name. Auto-generated if None.
|
||||
doc_name: Target document. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with created object information:
|
||||
- name: Object name
|
||||
- label: Object label
|
||||
- volume: Box volume
|
||||
- type_id: Object type
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
obj = await bridge.create_object(
|
||||
"Part::Box",
|
||||
name,
|
||||
{"Length": length, "Width": width, "Height": height},
|
||||
doc_name,
|
||||
)
|
||||
return {
|
||||
"name": obj.name,
|
||||
"label": obj.label,
|
||||
"type_id": obj.type_id,
|
||||
"volume": length * width * height,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def create_cylinder(
|
||||
radius: float = 5.0,
|
||||
height: float = 10.0,
|
||||
angle: float = 360.0,
|
||||
name: str | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a Part Cylinder primitive.
|
||||
|
||||
Args:
|
||||
radius: Cylinder radius. Defaults to 5.0.
|
||||
height: Cylinder height. Defaults to 10.0.
|
||||
angle: Sweep angle in degrees (for partial cylinder). Defaults to 360.
|
||||
name: Object name. Auto-generated if None.
|
||||
doc_name: Target document. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with created object information:
|
||||
- name: Object name
|
||||
- label: Object label
|
||||
- type_id: Object type
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
obj = await bridge.create_object(
|
||||
"Part::Cylinder",
|
||||
name,
|
||||
{"Radius": radius, "Height": height, "Angle": angle},
|
||||
doc_name,
|
||||
)
|
||||
return {
|
||||
"name": obj.name,
|
||||
"label": obj.label,
|
||||
"type_id": obj.type_id,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def create_sphere(
|
||||
radius: float = 5.0,
|
||||
name: str | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a Part Sphere primitive.
|
||||
|
||||
Args:
|
||||
radius: Sphere radius. Defaults to 5.0.
|
||||
name: Object name. Auto-generated if None.
|
||||
doc_name: Target document. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with created object information:
|
||||
- name: Object name
|
||||
- label: Object label
|
||||
- type_id: Object type
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
obj = await bridge.create_object(
|
||||
"Part::Sphere",
|
||||
name,
|
||||
{"Radius": radius},
|
||||
doc_name,
|
||||
)
|
||||
return {
|
||||
"name": obj.name,
|
||||
"label": obj.label,
|
||||
"type_id": obj.type_id,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def create_cone(
|
||||
radius1: float = 5.0,
|
||||
radius2: float = 0.0,
|
||||
height: float = 10.0,
|
||||
angle: float = 360.0,
|
||||
name: str | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a Part Cone primitive.
|
||||
|
||||
Args:
|
||||
radius1: Bottom radius. Defaults to 5.0.
|
||||
radius2: Top radius (0 for pointed cone). Defaults to 0.0.
|
||||
height: Cone height. Defaults to 10.0.
|
||||
angle: Sweep angle in degrees (for partial cone). Defaults to 360.
|
||||
name: Object name. Auto-generated if None.
|
||||
doc_name: Target document. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with created object information:
|
||||
- name: Object name
|
||||
- label: Object label
|
||||
- type_id: Object type
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
obj = await bridge.create_object(
|
||||
"Part::Cone",
|
||||
name,
|
||||
{"Radius1": radius1, "Radius2": radius2, "Height": height, "Angle": angle},
|
||||
doc_name,
|
||||
)
|
||||
return {
|
||||
"name": obj.name,
|
||||
"label": obj.label,
|
||||
"type_id": obj.type_id,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def create_torus(
|
||||
radius1: float = 10.0,
|
||||
radius2: float = 2.0,
|
||||
angle1: float = -180.0,
|
||||
angle2: float = 180.0,
|
||||
angle3: float = 360.0,
|
||||
name: str | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a Part Torus (donut shape) primitive.
|
||||
|
||||
Args:
|
||||
radius1: Major radius (center to tube center). Defaults to 10.0.
|
||||
radius2: Minor radius (tube radius). Defaults to 2.0.
|
||||
angle1: Start angle for tube sweep. Defaults to -180.
|
||||
angle2: End angle for tube sweep. Defaults to 180.
|
||||
angle3: Rotation angle around axis. Defaults to 360.
|
||||
name: Object name. Auto-generated if None.
|
||||
doc_name: Target document. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with created object information:
|
||||
- name: Object name
|
||||
- label: Object label
|
||||
- type_id: Object type
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
obj = await bridge.create_object(
|
||||
"Part::Torus",
|
||||
name,
|
||||
{
|
||||
"Radius1": radius1,
|
||||
"Radius2": radius2,
|
||||
"Angle1": angle1,
|
||||
"Angle2": angle2,
|
||||
"Angle3": angle3,
|
||||
},
|
||||
doc_name,
|
||||
)
|
||||
return {
|
||||
"name": obj.name,
|
||||
"label": obj.label,
|
||||
"type_id": obj.type_id,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def create_wedge(
|
||||
xmin: float = 0.0,
|
||||
ymin: float = 0.0,
|
||||
zmin: float = 0.0,
|
||||
x2min: float = 2.0,
|
||||
z2min: float = 2.0,
|
||||
xmax: float = 10.0,
|
||||
ymax: float = 10.0,
|
||||
zmax: float = 10.0,
|
||||
x2max: float = 8.0,
|
||||
z2max: float = 8.0,
|
||||
name: str | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a Part Wedge primitive.
|
||||
|
||||
A wedge is a tapered box shape useful for ramps and similar geometry.
|
||||
|
||||
Args:
|
||||
xmin: Minimum X at base. Defaults to 0.0.
|
||||
ymin: Minimum Y (base position). Defaults to 0.0.
|
||||
zmin: Minimum Z at base. Defaults to 0.0.
|
||||
x2min: Minimum X at top. Defaults to 2.0.
|
||||
z2min: Minimum Z at top. Defaults to 2.0.
|
||||
xmax: Maximum X at base. Defaults to 10.0.
|
||||
ymax: Maximum Y (top position). Defaults to 10.0.
|
||||
zmax: Maximum Z at base. Defaults to 10.0.
|
||||
x2max: Maximum X at top. Defaults to 8.0.
|
||||
z2max: Maximum Z at top. Defaults to 8.0.
|
||||
name: Object name. Auto-generated if None.
|
||||
doc_name: Target document. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with created object information:
|
||||
- name: Object name
|
||||
- label: Object label
|
||||
- type_id: Object type
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
obj = await bridge.create_object(
|
||||
"Part::Wedge",
|
||||
name,
|
||||
{
|
||||
"Xmin": xmin,
|
||||
"Ymin": ymin,
|
||||
"Zmin": zmin,
|
||||
"X2min": x2min,
|
||||
"Z2min": z2min,
|
||||
"Xmax": xmax,
|
||||
"Ymax": ymax,
|
||||
"Zmax": zmax,
|
||||
"X2max": x2max,
|
||||
"Z2max": z2max,
|
||||
},
|
||||
doc_name,
|
||||
)
|
||||
return {
|
||||
"name": obj.name,
|
||||
"label": obj.label,
|
||||
"type_id": obj.type_id,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def create_helix(
|
||||
pitch: float = 5.0,
|
||||
height: float = 20.0,
|
||||
radius: float = 5.0,
|
||||
angle: float = 0.0,
|
||||
left_handed: bool = False,
|
||||
name: str | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a Part Helix curve.
|
||||
|
||||
A helix is a spiral curve, useful as a sweep path for threads and springs.
|
||||
|
||||
Args:
|
||||
pitch: Distance between turns. Defaults to 5.0.
|
||||
height: Total helix height. Defaults to 20.0.
|
||||
radius: Helix radius. Defaults to 5.0.
|
||||
angle: Taper angle in degrees. Defaults to 0.0.
|
||||
left_handed: Whether helix is left-handed. Defaults to False.
|
||||
name: Object name. Auto-generated if None.
|
||||
doc_name: Target document. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with created object information:
|
||||
- name: Object name
|
||||
- label: Object label
|
||||
- type_id: Object type
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
obj = await bridge.create_object(
|
||||
"Part::Helix",
|
||||
name,
|
||||
{
|
||||
"Pitch": pitch,
|
||||
"Height": height,
|
||||
"Radius": radius,
|
||||
"Angle": angle,
|
||||
"LocalCoord": 1 if left_handed else 0,
|
||||
},
|
||||
doc_name,
|
||||
)
|
||||
return {
|
||||
"name": obj.name,
|
||||
"label": obj.label,
|
||||
"type_id": obj.type_id,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def edit_object(
|
||||
object_name: str,
|
||||
properties: dict[str, Any],
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Edit properties of an existing FreeCAD object.
|
||||
|
||||
Args:
|
||||
object_name: Name of the object to edit.
|
||||
properties: Dictionary of property names and new values.
|
||||
doc_name: Document containing the object. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with updated object information:
|
||||
- name: Object name
|
||||
- label: Object label
|
||||
- type_id: Object type
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
obj = await bridge.edit_object(object_name, properties, doc_name)
|
||||
return {
|
||||
"name": obj.name,
|
||||
"label": obj.label,
|
||||
"type_id": obj.type_id,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def delete_object(
|
||||
object_name: str,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Delete an object from a FreeCAD document.
|
||||
|
||||
Args:
|
||||
object_name: Name of the object to delete.
|
||||
doc_name: Document containing the object. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with delete result:
|
||||
- success: Whether delete was successful
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
await bridge.delete_object(object_name, doc_name)
|
||||
return {"success": True}
|
||||
|
||||
@mcp.tool()
|
||||
async def boolean_operation(
|
||||
operation: str,
|
||||
object1_name: str,
|
||||
object2_name: str,
|
||||
result_name: str | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Perform a boolean operation on two FreeCAD objects.
|
||||
|
||||
Args:
|
||||
operation: Boolean operation type: "fuse" (union), "cut" (subtract),
|
||||
or "common" (intersection).
|
||||
object1_name: Name of the first object.
|
||||
object2_name: Name of the second object.
|
||||
result_name: Name for the result object. Auto-generated if None.
|
||||
doc_name: Document containing the objects. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with result object information:
|
||||
- name: Result object name
|
||||
- label: Result object label
|
||||
- type_id: Result object type
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
operation_map = {
|
||||
"fuse": "Part::MultiFuse",
|
||||
"cut": "Part::Cut",
|
||||
"common": "Part::MultiCommon",
|
||||
}
|
||||
|
||||
if operation not in operation_map:
|
||||
raise ValueError(f"Invalid operation: {operation}. Use: fuse, cut, common")
|
||||
|
||||
op_type = operation_map[operation]
|
||||
result_name = result_name or f"{operation.capitalize()}"
|
||||
|
||||
code = f"""
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
raise ValueError("No document found")
|
||||
|
||||
obj1 = doc.getObject({object1_name!r})
|
||||
obj2 = doc.getObject({object2_name!r})
|
||||
|
||||
if obj1 is None:
|
||||
raise ValueError(f"Object not found: {object1_name!r}")
|
||||
if obj2 is None:
|
||||
raise ValueError(f"Object not found: {object2_name!r}")
|
||||
|
||||
if {op_type!r} == "Part::Cut":
|
||||
result = doc.addObject({op_type!r}, {result_name!r})
|
||||
result.Base = obj1
|
||||
result.Tool = obj2
|
||||
else:
|
||||
result = doc.addObject({op_type!r}, {result_name!r})
|
||||
result.Shapes = [obj1, obj2]
|
||||
|
||||
doc.recompute()
|
||||
|
||||
_result_ = {{
|
||||
"name": result.Name,
|
||||
"label": result.Label,
|
||||
"type_id": result.TypeId,
|
||||
}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success:
|
||||
return result.result
|
||||
raise ValueError(result.error_traceback or "Boolean operation failed")
|
||||
|
||||
@mcp.tool()
|
||||
async def set_placement(
|
||||
object_name: str,
|
||||
position: list[float] | None = None,
|
||||
rotation: list[float] | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Set the placement (position and rotation) of a FreeCAD object.
|
||||
|
||||
Args:
|
||||
object_name: Name of the object to move.
|
||||
position: Position as [x, y, z]. Keeps current if None.
|
||||
rotation: Rotation as [yaw, pitch, roll] in degrees. Keeps current if None.
|
||||
doc_name: Document containing the object. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with new placement:
|
||||
- position: New position [x, y, z]
|
||||
- rotation: New rotation angles
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
pos_str = (
|
||||
f"FreeCAD.Vector({position[0]}, {position[1]}, {position[2]})"
|
||||
if position
|
||||
else "obj.Placement.Base"
|
||||
)
|
||||
rot_str = (
|
||||
f"FreeCAD.Rotation({rotation[0]}, {rotation[1]}, {rotation[2]})"
|
||||
if rotation
|
||||
else "obj.Placement.Rotation"
|
||||
)
|
||||
|
||||
code = f"""
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
raise ValueError("No document found")
|
||||
|
||||
obj = doc.getObject({object_name!r})
|
||||
if obj is None:
|
||||
raise ValueError(f"Object not found: {object_name!r}")
|
||||
|
||||
pos = {pos_str}
|
||||
rot = {rot_str}
|
||||
|
||||
obj.Placement = FreeCAD.Placement(pos, rot)
|
||||
doc.recompute()
|
||||
|
||||
_result_ = {{
|
||||
"position": [obj.Placement.Base.x, obj.Placement.Base.y, obj.Placement.Base.z],
|
||||
"rotation": list(obj.Placement.Rotation.toEuler()),
|
||||
}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success:
|
||||
return result.result
|
||||
raise ValueError(result.error_traceback or "Set placement failed")
|
||||
|
||||
@mcp.tool()
|
||||
async def scale_object(
|
||||
object_name: str,
|
||||
scale: float | list[float],
|
||||
result_name: str | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Scale an object uniformly or non-uniformly.
|
||||
|
||||
Creates a new scaled copy using Part.Scale.
|
||||
|
||||
Args:
|
||||
object_name: Name of the object to scale.
|
||||
scale: Scale factor. Can be:
|
||||
- A single float for uniform scaling
|
||||
- A list [sx, sy, sz] for non-uniform scaling
|
||||
result_name: Name for the result object. Auto-generated if None.
|
||||
doc_name: Document containing the object. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with result object information:
|
||||
- name: Result object name
|
||||
- label: Result object label
|
||||
- type_id: Result object type
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
if isinstance(scale, int | float):
|
||||
scale_vec = f"FreeCAD.Vector({scale}, {scale}, {scale})"
|
||||
else:
|
||||
scale_vec = f"FreeCAD.Vector({scale[0]}, {scale[1]}, {scale[2]})"
|
||||
|
||||
code = f"""
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
raise ValueError("No document found")
|
||||
|
||||
obj = doc.getObject({object_name!r})
|
||||
if obj is None:
|
||||
raise ValueError(f"Object not found: {object_name!r}")
|
||||
|
||||
if not hasattr(obj, "Shape"):
|
||||
raise ValueError("Object has no shape to scale")
|
||||
|
||||
import Part
|
||||
|
||||
scale_vec = {scale_vec}
|
||||
center = obj.Shape.BoundBox.Center
|
||||
|
||||
# Create scaled shape
|
||||
mat = FreeCAD.Matrix()
|
||||
mat.scale(scale_vec)
|
||||
scaled_shape = obj.Shape.transformGeometry(mat)
|
||||
|
||||
# Create result object
|
||||
result_name = {result_name!r} or f"{{obj.Name}}_scaled"
|
||||
result = doc.addObject("Part::Feature", result_name)
|
||||
result.Shape = scaled_shape
|
||||
|
||||
doc.recompute()
|
||||
|
||||
_result_ = {{
|
||||
"name": result.Name,
|
||||
"label": result.Label,
|
||||
"type_id": result.TypeId,
|
||||
}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success:
|
||||
return result.result
|
||||
raise ValueError(result.error_traceback or "Scale operation failed")
|
||||
|
||||
@mcp.tool()
|
||||
async def rotate_object(
|
||||
object_name: str,
|
||||
axis: list[float],
|
||||
angle: float,
|
||||
center: list[float] | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Rotate an object around an axis.
|
||||
|
||||
Modifies the object's placement in-place.
|
||||
|
||||
Args:
|
||||
object_name: Name of the object to rotate.
|
||||
axis: Rotation axis as [x, y, z] vector.
|
||||
angle: Rotation angle in degrees.
|
||||
center: Center point for rotation [x, y, z].
|
||||
Uses object center if None.
|
||||
doc_name: Document containing the object. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with new placement:
|
||||
- position: New position [x, y, z]
|
||||
- rotation: New rotation angles
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
center_str = (
|
||||
f"FreeCAD.Vector({center[0]}, {center[1]}, {center[2]})"
|
||||
if center
|
||||
else "obj.Shape.BoundBox.Center if hasattr(obj, 'Shape') else FreeCAD.Vector(0,0,0)"
|
||||
)
|
||||
|
||||
code = f"""
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
raise ValueError("No document found")
|
||||
|
||||
obj = doc.getObject({object_name!r})
|
||||
if obj is None:
|
||||
raise ValueError(f"Object not found: {object_name!r}")
|
||||
|
||||
axis = FreeCAD.Vector({axis[0]}, {axis[1]}, {axis[2]})
|
||||
center = {center_str}
|
||||
|
||||
# Create rotation
|
||||
rot = FreeCAD.Rotation(axis, {angle})
|
||||
|
||||
# Apply rotation around center
|
||||
old_placement = obj.Placement
|
||||
new_rot = rot.multiply(old_placement.Rotation)
|
||||
|
||||
# Adjust position for rotation around center
|
||||
pos_vec = old_placement.Base - center
|
||||
rotated_pos = rot.multVec(pos_vec) + center
|
||||
|
||||
obj.Placement = FreeCAD.Placement(rotated_pos, new_rot)
|
||||
doc.recompute()
|
||||
|
||||
_result_ = {{
|
||||
"position": [obj.Placement.Base.x, obj.Placement.Base.y, obj.Placement.Base.z],
|
||||
"rotation": list(obj.Placement.Rotation.toEuler()),
|
||||
}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success:
|
||||
return result.result
|
||||
raise ValueError(result.error_traceback or "Rotate operation failed")
|
||||
|
||||
@mcp.tool()
|
||||
async def copy_object(
|
||||
object_name: str,
|
||||
new_name: str | None = None,
|
||||
offset: list[float] | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a copy of an object.
|
||||
|
||||
Args:
|
||||
object_name: Name of the object to copy.
|
||||
new_name: Name for the copy. Auto-generated if None.
|
||||
offset: Position offset [x, y, z] for the copy. [0,0,0] if None.
|
||||
doc_name: Document containing the object. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with copy object information:
|
||||
- name: Copy object name
|
||||
- label: Copy object label
|
||||
- type_id: Copy object type
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
offset_str = (
|
||||
f"[{offset[0]}, {offset[1]}, {offset[2]}]" if offset else "[0, 0, 0]"
|
||||
)
|
||||
|
||||
code = f"""
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
raise ValueError("No document found")
|
||||
|
||||
obj = doc.getObject({object_name!r})
|
||||
if obj is None:
|
||||
raise ValueError(f"Object not found: {object_name!r}")
|
||||
|
||||
# Create copy
|
||||
new_name = {new_name!r} or f"{{obj.Name}}_copy"
|
||||
|
||||
if hasattr(obj, "Shape"):
|
||||
copy_obj = doc.addObject("Part::Feature", new_name)
|
||||
copy_obj.Shape = obj.Shape.copy()
|
||||
else:
|
||||
# For non-shape objects, create simple copy
|
||||
copy_obj = doc.copyObject(obj, False)
|
||||
copy_obj.Label = new_name
|
||||
|
||||
# Apply offset
|
||||
offset = {offset_str}
|
||||
copy_obj.Placement.Base = FreeCAD.Vector(
|
||||
obj.Placement.Base.x + offset[0],
|
||||
obj.Placement.Base.y + offset[1],
|
||||
obj.Placement.Base.z + offset[2]
|
||||
)
|
||||
|
||||
doc.recompute()
|
||||
|
||||
_result_ = {{
|
||||
"name": copy_obj.Name,
|
||||
"label": copy_obj.Label,
|
||||
"type_id": copy_obj.TypeId,
|
||||
}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success:
|
||||
return result.result
|
||||
raise ValueError(result.error_traceback or "Copy operation failed")
|
||||
|
||||
@mcp.tool()
|
||||
async def mirror_object(
|
||||
object_name: str,
|
||||
plane: str = "XY",
|
||||
result_name: str | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Mirror an object across a plane.
|
||||
|
||||
Creates a new mirrored copy of the object.
|
||||
|
||||
Args:
|
||||
object_name: Name of the object to mirror.
|
||||
plane: Mirror plane. Options: "XY", "XZ", "YZ".
|
||||
result_name: Name for the result object. Auto-generated if None.
|
||||
doc_name: Document containing the object. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with result object information:
|
||||
- name: Result object name
|
||||
- label: Result object label
|
||||
- type_id: Result object type
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
plane_map = {
|
||||
"XY": "(0, 0, 1)",
|
||||
"XZ": "(0, 1, 0)",
|
||||
"YZ": "(1, 0, 0)",
|
||||
}
|
||||
|
||||
if plane not in plane_map:
|
||||
raise ValueError(f"Invalid plane: {plane}. Use: XY, XZ, YZ")
|
||||
|
||||
normal = plane_map[plane]
|
||||
|
||||
code = f"""
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
raise ValueError("No document found")
|
||||
|
||||
obj = doc.getObject({object_name!r})
|
||||
if obj is None:
|
||||
raise ValueError(f"Object not found: {object_name!r}")
|
||||
|
||||
if not hasattr(obj, "Shape"):
|
||||
raise ValueError("Object has no shape to mirror")
|
||||
|
||||
# Create mirror matrix
|
||||
import Part
|
||||
normal = FreeCAD.Vector{normal}
|
||||
center = obj.Shape.BoundBox.Center
|
||||
|
||||
# Mirror the shape
|
||||
mirrored = obj.Shape.mirror(center, normal)
|
||||
|
||||
# Create result object
|
||||
result_name = {result_name!r} or f"{{obj.Name}}_mirror"
|
||||
result = doc.addObject("Part::Feature", result_name)
|
||||
result.Shape = mirrored
|
||||
|
||||
doc.recompute()
|
||||
|
||||
_result_ = {{
|
||||
"name": result.Name,
|
||||
"label": result.Label,
|
||||
"type_id": result.TypeId,
|
||||
}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success:
|
||||
return result.result
|
||||
raise ValueError(result.error_traceback or "Mirror operation failed")
|
||||
|
||||
@mcp.tool()
|
||||
async def get_selection(doc_name: str | None = None) -> list[dict[str, Any]]:
|
||||
"""Get the current selection in FreeCAD.
|
||||
|
||||
Requires GUI mode.
|
||||
|
||||
Args:
|
||||
doc_name: Document to check selection in. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
List of selected objects with:
|
||||
- name: Object name
|
||||
- label: Object label
|
||||
- type_id: Object type
|
||||
- sub_elements: List of selected sub-elements (e.g., ["Face1", "Edge2"])
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
code = f"""
|
||||
if not FreeCAD.GuiUp:
|
||||
_result_ = []
|
||||
else:
|
||||
sel = FreeCADGui.Selection.getSelectionEx({doc_name!r})
|
||||
_result_ = []
|
||||
for s in sel:
|
||||
_result_.append({{
|
||||
"name": s.Object.Name,
|
||||
"label": s.Object.Label,
|
||||
"type_id": s.Object.TypeId,
|
||||
"sub_elements": list(s.SubElementNames) if s.SubElementNames else [],
|
||||
}})
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success:
|
||||
return result.result
|
||||
return []
|
||||
|
||||
@mcp.tool()
|
||||
async def set_selection(
|
||||
object_names: list[str],
|
||||
clear_existing: bool = True,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Set the selection in FreeCAD.
|
||||
|
||||
Requires GUI mode.
|
||||
|
||||
Args:
|
||||
object_names: List of object names to select.
|
||||
clear_existing: Whether to clear existing selection first. Defaults to True.
|
||||
doc_name: Document containing the objects. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with result:
|
||||
- success: Whether operation was successful
|
||||
- selected_count: Number of objects selected
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
code = f"""
|
||||
if not FreeCAD.GuiUp:
|
||||
_result_ = {{"success": False, "error": "GUI not available"}}
|
||||
else:
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
raise ValueError("No document found")
|
||||
|
||||
if {clear_existing}:
|
||||
FreeCADGui.Selection.clearSelection()
|
||||
|
||||
count = 0
|
||||
for name in {object_names!r}:
|
||||
obj = doc.getObject(name)
|
||||
if obj:
|
||||
FreeCADGui.Selection.addSelection(obj)
|
||||
count += 1
|
||||
|
||||
_result_ = {{"success": True, "selected_count": count}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success:
|
||||
return result.result
|
||||
raise ValueError(result.error_traceback or "Set selection failed")
|
||||
|
||||
@mcp.tool()
|
||||
async def clear_selection() -> dict[str, Any]:
|
||||
"""Clear the current selection in FreeCAD.
|
||||
|
||||
Requires GUI mode.
|
||||
|
||||
Returns:
|
||||
Dictionary with result:
|
||||
- success: Whether operation was successful
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
code = """
|
||||
if not FreeCAD.GuiUp:
|
||||
_result_ = {"success": False, "error": "GUI not available"}
|
||||
else:
|
||||
FreeCADGui.Selection.clearSelection()
|
||||
_result_ = {"success": True}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success:
|
||||
return result.result
|
||||
raise ValueError(result.error_traceback or "Clear selection failed")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,782 @@
|
||||
"""View and screenshot tools for FreeCAD MCP server.
|
||||
|
||||
This module provides tools for controlling the 3D view and
|
||||
capturing screenshots. Based on learnings from neka-nat which
|
||||
has excellent screenshot handling with view type detection.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def register_view_tools(mcp, get_bridge) -> None:
|
||||
"""Register view-related tools with the MCP server.
|
||||
|
||||
Args:
|
||||
mcp: The FastMCP server instance.
|
||||
get_bridge: Async function to get the active bridge.
|
||||
"""
|
||||
|
||||
@mcp.tool()
|
||||
async def get_screenshot(
|
||||
view_angle: str = "Isometric",
|
||||
width: int = 800,
|
||||
height: int = 600,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Capture a screenshot of the FreeCAD 3D view.
|
||||
|
||||
Requires GUI mode - will return an error in headless mode.
|
||||
|
||||
Args:
|
||||
view_angle: View angle to set before capture. Options:
|
||||
- "Isometric" - 3D isometric view (default)
|
||||
- "Front" - Front view (XZ plane)
|
||||
- "Back" - Back view
|
||||
- "Top" - Top view (XY plane)
|
||||
- "Bottom" - Bottom view
|
||||
- "Left" - Left view (YZ plane)
|
||||
- "Right" - Right view
|
||||
- "FitAll" - Fit all objects in view
|
||||
width: Image width in pixels. Defaults to 800.
|
||||
height: Image height in pixels. Defaults to 600.
|
||||
doc_name: Document to capture. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with screenshot result:
|
||||
- success: Whether capture was successful
|
||||
- data: Base64-encoded PNG image data (if success)
|
||||
- format: Image format ("png")
|
||||
- width: Actual image width
|
||||
- height: Actual image height
|
||||
- error: Error message (if not success)
|
||||
"""
|
||||
from freecad_mcp.bridge.base import ViewAngle
|
||||
|
||||
# Map string to ViewAngle enum
|
||||
angle_map = {
|
||||
"Isometric": ViewAngle.ISOMETRIC,
|
||||
"Front": ViewAngle.FRONT,
|
||||
"Back": ViewAngle.BACK,
|
||||
"Top": ViewAngle.TOP,
|
||||
"Bottom": ViewAngle.BOTTOM,
|
||||
"Left": ViewAngle.LEFT,
|
||||
"Right": ViewAngle.RIGHT,
|
||||
"FitAll": ViewAngle.FIT_ALL,
|
||||
}
|
||||
|
||||
if view_angle not in angle_map:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Invalid view_angle: {view_angle}. Options: {list(angle_map.keys())}",
|
||||
}
|
||||
|
||||
bridge = await get_bridge()
|
||||
result = await bridge.get_screenshot(
|
||||
view_angle=angle_map[view_angle],
|
||||
width=width,
|
||||
height=height,
|
||||
doc_name=doc_name,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": result.success,
|
||||
"data": result.data,
|
||||
"format": result.format,
|
||||
"width": result.width,
|
||||
"height": result.height,
|
||||
"error": result.error,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def set_view_angle(
|
||||
view_angle: str,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Set the 3D view angle.
|
||||
|
||||
Args:
|
||||
view_angle: View angle to set. Options:
|
||||
- "Isometric" - 3D isometric view
|
||||
- "Front" - Front view (XZ plane)
|
||||
- "Back" - Back view
|
||||
- "Top" - Top view (XY plane)
|
||||
- "Bottom" - Bottom view
|
||||
- "Left" - Left view (YZ plane)
|
||||
- "Right" - Right view
|
||||
- "FitAll" - Fit all objects in view
|
||||
doc_name: Document to set view for. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with result:
|
||||
- success: Whether operation was successful
|
||||
"""
|
||||
from freecad_mcp.bridge.base import ViewAngle
|
||||
|
||||
angle_map = {
|
||||
"Isometric": ViewAngle.ISOMETRIC,
|
||||
"Front": ViewAngle.FRONT,
|
||||
"Back": ViewAngle.BACK,
|
||||
"Top": ViewAngle.TOP,
|
||||
"Bottom": ViewAngle.BOTTOM,
|
||||
"Left": ViewAngle.LEFT,
|
||||
"Right": ViewAngle.RIGHT,
|
||||
"FitAll": ViewAngle.FIT_ALL,
|
||||
}
|
||||
|
||||
if view_angle not in angle_map:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Invalid view_angle: {view_angle}. Options: {list(angle_map.keys())}",
|
||||
}
|
||||
|
||||
bridge = await get_bridge()
|
||||
await bridge.set_view(angle_map[view_angle], doc_name)
|
||||
return {"success": True}
|
||||
|
||||
@mcp.tool()
|
||||
async def list_workbenches() -> list[dict[str, Any]]:
|
||||
"""List all available FreeCAD workbenches.
|
||||
|
||||
Returns:
|
||||
List of dictionaries, each containing:
|
||||
- name: Workbench internal name
|
||||
- label: Display label
|
||||
- is_active: Whether workbench is currently active
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
workbenches = await bridge.get_workbenches()
|
||||
return [
|
||||
{
|
||||
"name": wb.name,
|
||||
"label": wb.label,
|
||||
"is_active": wb.is_active,
|
||||
}
|
||||
for wb in workbenches
|
||||
]
|
||||
|
||||
@mcp.tool()
|
||||
async def activate_workbench(workbench_name: str) -> dict[str, Any]:
|
||||
"""Activate a FreeCAD workbench.
|
||||
|
||||
Args:
|
||||
workbench_name: Internal name of the workbench to activate.
|
||||
Common workbenches:
|
||||
- "PartWorkbench" - Part modeling
|
||||
- "PartDesignWorkbench" - Parametric part design
|
||||
- "SketcherWorkbench" - 2D sketching
|
||||
- "DraftWorkbench" - 2D drafting
|
||||
- "MeshWorkbench" - Mesh operations
|
||||
- "SpreadsheetWorkbench" - Spreadsheet
|
||||
|
||||
Returns:
|
||||
Dictionary with result:
|
||||
- success: Whether activation was successful
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
await bridge.activate_workbench(workbench_name)
|
||||
return {"success": True}
|
||||
|
||||
@mcp.tool()
|
||||
async def fit_all(doc_name: str | None = None) -> dict[str, Any]:
|
||||
"""Fit all objects in the current view.
|
||||
|
||||
Adjusts the camera to show all visible objects in the document.
|
||||
|
||||
Args:
|
||||
doc_name: Document to fit view for. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with result:
|
||||
- success: Whether operation was successful
|
||||
"""
|
||||
from freecad_mcp.bridge.base import ViewAngle
|
||||
|
||||
bridge = await get_bridge()
|
||||
await bridge.set_view(ViewAngle.FIT_ALL, doc_name)
|
||||
return {"success": True}
|
||||
|
||||
@mcp.tool()
|
||||
async def set_object_visibility(
|
||||
object_name: str,
|
||||
visible: bool,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Set the visibility of a FreeCAD object.
|
||||
|
||||
Args:
|
||||
object_name: Name of the object.
|
||||
visible: Whether object should be visible.
|
||||
doc_name: Document containing the object. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with result:
|
||||
- success: Whether operation was successful
|
||||
- visible: New visibility state
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
code = f"""
|
||||
if not FreeCAD.GuiUp:
|
||||
_result_ = {{"success": False, "error": "GUI not available - visibility cannot be set in headless mode"}}
|
||||
else:
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
_result_ = {{"success": False, "error": "No document found"}}
|
||||
else:
|
||||
obj = doc.getObject({object_name!r})
|
||||
if obj is None:
|
||||
_result_ = {{"success": False, "error": f"Object not found: {object_name!r}"}}
|
||||
elif hasattr(obj, "ViewObject") and obj.ViewObject:
|
||||
obj.ViewObject.Visibility = {visible}
|
||||
_result_ = {{"success": True, "visible": {visible}}}
|
||||
else:
|
||||
_result_ = {{"success": False, "error": "Object has no ViewObject"}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success and result.result:
|
||||
return result.result
|
||||
return {
|
||||
"success": False,
|
||||
"error": result.error_traceback or "Set visibility failed",
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def set_display_mode(
|
||||
object_name: str,
|
||||
mode: str,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Set the display mode of a FreeCAD object.
|
||||
|
||||
Args:
|
||||
object_name: Name of the object.
|
||||
mode: Display mode. Common options:
|
||||
- "Flat Lines" - Solid with edges
|
||||
- "Shaded" - Solid without edges
|
||||
- "Wireframe" - Wire frame only
|
||||
- "Points" - Points only
|
||||
doc_name: Document containing the object. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with result:
|
||||
- success: Whether operation was successful
|
||||
- mode: New display mode
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
code = f"""
|
||||
if not FreeCAD.GuiUp:
|
||||
_result_ = {{"success": False, "error": "GUI not available - display mode cannot be set in headless mode"}}
|
||||
else:
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
_result_ = {{"success": False, "error": "No document found"}}
|
||||
else:
|
||||
obj = doc.getObject({object_name!r})
|
||||
if obj is None:
|
||||
_result_ = {{"success": False, "error": f"Object not found: {object_name!r}"}}
|
||||
elif hasattr(obj, "ViewObject") and obj.ViewObject:
|
||||
obj.ViewObject.DisplayMode = {mode!r}
|
||||
_result_ = {{"success": True, "mode": {mode!r}}}
|
||||
else:
|
||||
_result_ = {{"success": False, "error": "Object has no ViewObject"}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success and result.result:
|
||||
return result.result
|
||||
return {
|
||||
"success": False,
|
||||
"error": result.error_traceback or "Set display mode failed",
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def set_object_color(
|
||||
object_name: str,
|
||||
color: list[float],
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Set the color of a FreeCAD object.
|
||||
|
||||
Args:
|
||||
object_name: Name of the object.
|
||||
color: RGB color as [r, g, b] where each value is 0.0-1.0.
|
||||
Example: [1.0, 0.0, 0.0] for red.
|
||||
doc_name: Document containing the object. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with result:
|
||||
- success: Whether operation was successful
|
||||
- color: New color values
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
if len(color) != 3:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Color must be [r, g, b] with values 0.0-1.0",
|
||||
}
|
||||
|
||||
code = f"""
|
||||
if not FreeCAD.GuiUp:
|
||||
_result_ = {{"success": False, "error": "GUI not available - color cannot be set in headless mode"}}
|
||||
else:
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
_result_ = {{"success": False, "error": "No document found"}}
|
||||
else:
|
||||
obj = doc.getObject({object_name!r})
|
||||
if obj is None:
|
||||
_result_ = {{"success": False, "error": f"Object not found: {object_name!r}"}}
|
||||
elif hasattr(obj, "ViewObject") and obj.ViewObject:
|
||||
obj.ViewObject.ShapeColor = ({color[0]}, {color[1]}, {color[2]})
|
||||
_result_ = {{"success": True, "color": {color}}}
|
||||
else:
|
||||
_result_ = {{"success": False, "error": "Object has no ViewObject"}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success and result.result:
|
||||
return result.result
|
||||
return {
|
||||
"success": False,
|
||||
"error": result.error_traceback or "Set color failed",
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def zoom_in(
|
||||
factor: float = 1.5, doc_name: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Zoom in the 3D view.
|
||||
|
||||
Requires GUI mode.
|
||||
|
||||
Args:
|
||||
factor: Zoom factor (>1 zooms in). Defaults to 1.5.
|
||||
doc_name: Document to zoom in. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with result:
|
||||
- success: Whether operation was successful
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
code = f"""
|
||||
if not FreeCAD.GuiUp:
|
||||
_result_ = {{"success": False, "error": "GUI not available - zoom requires GUI mode"}}
|
||||
else:
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
_result_ = {{"success": False, "error": "No document found"}}
|
||||
elif FreeCADGui.ActiveDocument is None or FreeCADGui.ActiveDocument.ActiveView is None:
|
||||
_result_ = {{"success": False, "error": "No active view"}}
|
||||
else:
|
||||
view = FreeCADGui.ActiveDocument.ActiveView
|
||||
cam = view.getCameraNode()
|
||||
if hasattr(cam, "scaleHeight"):
|
||||
# For orthographic views
|
||||
cam.scaleHeight(1.0 / {factor})
|
||||
else:
|
||||
# For perspective views - move camera closer
|
||||
view.zoomIn()
|
||||
_result_ = {{"success": True}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success and result.result:
|
||||
return result.result
|
||||
return {
|
||||
"success": False,
|
||||
"error": result.error_traceback or "Zoom in failed",
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def zoom_out(
|
||||
factor: float = 1.5, doc_name: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Zoom out the 3D view.
|
||||
|
||||
Requires GUI mode.
|
||||
|
||||
Args:
|
||||
factor: Zoom factor (>1 zooms out). Defaults to 1.5.
|
||||
doc_name: Document to zoom out. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with result:
|
||||
- success: Whether operation was successful
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
code = f"""
|
||||
if not FreeCAD.GuiUp:
|
||||
_result_ = {{"success": False, "error": "GUI not available - zoom requires GUI mode"}}
|
||||
else:
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
_result_ = {{"success": False, "error": "No document found"}}
|
||||
elif FreeCADGui.ActiveDocument is None or FreeCADGui.ActiveDocument.ActiveView is None:
|
||||
_result_ = {{"success": False, "error": "No active view"}}
|
||||
else:
|
||||
view = FreeCADGui.ActiveDocument.ActiveView
|
||||
cam = view.getCameraNode()
|
||||
if hasattr(cam, "scaleHeight"):
|
||||
# For orthographic views
|
||||
cam.scaleHeight({factor})
|
||||
else:
|
||||
# For perspective views - move camera farther
|
||||
view.zoomOut()
|
||||
_result_ = {{"success": True}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success and result.result:
|
||||
return result.result
|
||||
return {
|
||||
"success": False,
|
||||
"error": result.error_traceback or "Zoom out failed",
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def set_camera_position(
|
||||
position: list[float],
|
||||
look_at: list[float] | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Set the camera position and orientation.
|
||||
|
||||
Requires GUI mode.
|
||||
|
||||
Args:
|
||||
position: Camera position as [x, y, z].
|
||||
look_at: Point to look at as [x, y, z]. Uses origin if None.
|
||||
doc_name: Document to set camera for. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with result:
|
||||
- success: Whether operation was successful
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
look_str = (
|
||||
f"FreeCAD.Vector({look_at[0]}, {look_at[1]}, {look_at[2]})"
|
||||
if look_at
|
||||
else "FreeCAD.Vector(0, 0, 0)"
|
||||
)
|
||||
|
||||
code = f"""
|
||||
if not FreeCAD.GuiUp:
|
||||
_result_ = {{"success": False, "error": "GUI not available - camera position requires GUI mode"}}
|
||||
else:
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
_result_ = {{"success": False, "error": "No document found"}}
|
||||
elif FreeCADGui.ActiveDocument is None or FreeCADGui.ActiveDocument.ActiveView is None:
|
||||
_result_ = {{"success": False, "error": "No active view"}}
|
||||
else:
|
||||
view = FreeCADGui.ActiveDocument.ActiveView
|
||||
pos = FreeCAD.Vector({position[0]}, {position[1]}, {position[2]})
|
||||
look_at = {look_str}
|
||||
|
||||
# Calculate direction
|
||||
direction = look_at - pos
|
||||
direction.normalize()
|
||||
|
||||
# Set camera
|
||||
view.setCameraOrientation(FreeCAD.Rotation(FreeCAD.Vector(0, 0, -1), direction))
|
||||
cam = view.getCameraNode()
|
||||
cam.position.setValue(pos.x, pos.y, pos.z)
|
||||
|
||||
_result_ = {{"success": True}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success and result.result:
|
||||
return result.result
|
||||
return {
|
||||
"success": False,
|
||||
"error": result.error_traceback or "Set camera position failed",
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def undo(doc_name: str | None = None) -> dict[str, Any]:
|
||||
"""Undo the last operation.
|
||||
|
||||
Args:
|
||||
doc_name: Document to undo in. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with result:
|
||||
- success: Whether undo was performed
|
||||
- can_undo: Whether more undos are available
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
code = f"""
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
_result_ = {{"success": False, "can_undo": False, "error": "No document found"}}
|
||||
elif doc.UndoCount > 0:
|
||||
doc.undo()
|
||||
_result_ = {{"success": True, "can_undo": doc.UndoCount > 0}}
|
||||
else:
|
||||
_result_ = {{"success": False, "can_undo": False, "error": "Nothing to undo"}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success and result.result:
|
||||
return result.result
|
||||
return {
|
||||
"success": False,
|
||||
"can_undo": False,
|
||||
"error": result.error_traceback or "Undo failed",
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def redo(doc_name: str | None = None) -> dict[str, Any]:
|
||||
"""Redo the last undone operation.
|
||||
|
||||
Args:
|
||||
doc_name: Document to redo in. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with result:
|
||||
- success: Whether redo was performed
|
||||
- can_redo: Whether more redos are available
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
code = f"""
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
_result_ = {{"success": False, "can_redo": False, "error": "No document found"}}
|
||||
elif doc.RedoCount > 0:
|
||||
doc.redo()
|
||||
_result_ = {{"success": True, "can_redo": doc.RedoCount > 0}}
|
||||
else:
|
||||
_result_ = {{"success": False, "can_redo": False, "error": "Nothing to redo"}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success and result.result:
|
||||
return result.result
|
||||
return {
|
||||
"success": False,
|
||||
"can_redo": False,
|
||||
"error": result.error_traceback or "Redo failed",
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def get_undo_redo_status(doc_name: str | None = None) -> dict[str, Any]:
|
||||
"""Get the current undo/redo status.
|
||||
|
||||
Args:
|
||||
doc_name: Document to check. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with status:
|
||||
- undo_count: Number of undoable operations
|
||||
- redo_count: Number of redoable operations
|
||||
- undo_names: List of undo operation names (if available)
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
code = f"""
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
_result_ = {{"error": "No document found", "undo_count": 0, "redo_count": 0, "undo_names": []}}
|
||||
else:
|
||||
_result_ = {{
|
||||
"undo_count": doc.UndoCount,
|
||||
"redo_count": doc.RedoCount,
|
||||
"undo_names": list(doc.UndoNames) if hasattr(doc, "UndoNames") else [],
|
||||
}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success and result.result:
|
||||
return result.result
|
||||
return {
|
||||
"error": result.error_traceback or "Get undo/redo status failed",
|
||||
"undo_count": 0,
|
||||
"redo_count": 0,
|
||||
"undo_names": [],
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def list_parts_library() -> list[dict[str, Any]]:
|
||||
"""List available parts from the FreeCAD parts library.
|
||||
|
||||
Returns:
|
||||
List of parts with:
|
||||
- name: Part filename
|
||||
- path: Full path to part file
|
||||
- category: Part category/folder
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
code = """
|
||||
import os
|
||||
|
||||
parts = []
|
||||
|
||||
# Get parts library paths
|
||||
try:
|
||||
# Standard library path
|
||||
lib_path = FreeCAD.getResourceDir() + "Mod/Parts_Library"
|
||||
if not os.path.exists(lib_path):
|
||||
lib_path = os.path.expanduser("~/.FreeCAD/Mod/PartsLibrary")
|
||||
|
||||
if os.path.exists(lib_path):
|
||||
for root, dirs, files in os.walk(lib_path):
|
||||
category = os.path.relpath(root, lib_path)
|
||||
if category == ".":
|
||||
category = "Root"
|
||||
|
||||
for f in files:
|
||||
if f.endswith((".FCStd", ".step", ".stp", ".iges", ".igs")):
|
||||
parts.append({
|
||||
"name": f,
|
||||
"path": os.path.join(root, f),
|
||||
"category": category,
|
||||
})
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
_result_ = parts
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success:
|
||||
return result.result
|
||||
return []
|
||||
|
||||
@mcp.tool()
|
||||
async def insert_part_from_library(
|
||||
part_path: str,
|
||||
name: str | None = None,
|
||||
position: list[float] | None = None,
|
||||
doc_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Insert a part from the parts library into the document.
|
||||
|
||||
Args:
|
||||
part_path: Path to the part file.
|
||||
name: Name for the inserted part. Auto-generated if None.
|
||||
position: Initial position [x, y, z]. Origin if None.
|
||||
doc_name: Target document. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with inserted part information:
|
||||
- name: Part name
|
||||
- label: Part label
|
||||
- type_id: Part type
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
pos_str = (
|
||||
f"FreeCAD.Vector({position[0]}, {position[1]}, {position[2]})"
|
||||
if position
|
||||
else "FreeCAD.Vector(0, 0, 0)"
|
||||
)
|
||||
|
||||
code = f"""
|
||||
import os
|
||||
import Part
|
||||
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
doc = FreeCAD.newDocument("Unnamed")
|
||||
|
||||
part_path = {part_path!r}
|
||||
if not os.path.exists(part_path):
|
||||
raise FileNotFoundError(f"Part file not found: {{part_path}}")
|
||||
|
||||
ext = os.path.splitext(part_path)[1].lower()
|
||||
part_name = {name!r} or os.path.splitext(os.path.basename(part_path))[0]
|
||||
|
||||
if ext == ".fcstd":
|
||||
# Import FreeCAD document
|
||||
src_doc = FreeCAD.openDocument(part_path)
|
||||
for obj in src_doc.Objects:
|
||||
if hasattr(obj, "Shape"):
|
||||
new_obj = doc.addObject("Part::Feature", part_name)
|
||||
new_obj.Shape = obj.Shape.copy()
|
||||
break
|
||||
FreeCAD.closeDocument(src_doc.Name)
|
||||
else:
|
||||
# Import STEP/IGES
|
||||
shape = Part.read(part_path)
|
||||
new_obj = doc.addObject("Part::Feature", part_name)
|
||||
new_obj.Shape = shape
|
||||
|
||||
# Set position
|
||||
new_obj.Placement.Base = {pos_str}
|
||||
|
||||
doc.recompute()
|
||||
|
||||
_result_ = {{
|
||||
"name": new_obj.Name,
|
||||
"label": new_obj.Label,
|
||||
"type_id": new_obj.TypeId,
|
||||
}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success and result.result:
|
||||
return result.result
|
||||
return {
|
||||
"success": False,
|
||||
"error": result.error_traceback or "Insert part from library failed",
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def get_console_log(lines: int = 50) -> dict[str, Any]:
|
||||
"""Get recent console output from FreeCAD.
|
||||
|
||||
Args:
|
||||
lines: Maximum number of lines to return. Defaults to 50.
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- messages: List of console messages
|
||||
- warnings: List of warning messages
|
||||
- errors: List of error messages
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
console_lines = await bridge.get_console_output(lines)
|
||||
|
||||
return {
|
||||
"messages": console_lines,
|
||||
"warnings": [line for line in console_lines if "warning" in line.lower()],
|
||||
"errors": [line for line in console_lines if "error" in line.lower()],
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def recompute(doc_name: str | None = None) -> dict[str, Any]:
|
||||
"""Force recompute of all objects in a document.
|
||||
|
||||
Args:
|
||||
doc_name: Document to recompute. Uses active document if None.
|
||||
|
||||
Returns:
|
||||
Dictionary with result:
|
||||
- success: Whether recompute completed
|
||||
- touch_count: Number of objects that were recomputed
|
||||
"""
|
||||
bridge = await get_bridge()
|
||||
|
||||
code = f"""
|
||||
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
||||
if doc is None:
|
||||
_result_ = {{"success": False, "error": "No document found", "touch_count": 0}}
|
||||
else:
|
||||
# Touch all objects to force recompute
|
||||
touch_count = 0
|
||||
for obj in doc.Objects:
|
||||
if hasattr(obj, "touch"):
|
||||
obj.touch()
|
||||
touch_count += 1
|
||||
|
||||
doc.recompute()
|
||||
|
||||
_result_ = {{"success": True, "touch_count": touch_count}}
|
||||
"""
|
||||
result = await bridge.execute_python(code)
|
||||
if result.success and result.result:
|
||||
return result.result
|
||||
return {
|
||||
"success": False,
|
||||
"error": result.error_traceback or "Recompute failed",
|
||||
"touch_count": 0,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Utility modules for FreeCAD MCP server.
|
||||
|
||||
This package contains shared utilities for serialization, validation, etc.
|
||||
"""
|
||||
@@ -0,0 +1 @@
|
||||
"""Test suite for FreeCAD MCP Server."""
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Pytest configuration and shared fixtures."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_python_code():
|
||||
"""Sample Python code for execution tests."""
|
||||
return """
|
||||
x = 1 + 1
|
||||
_result_ = {"value": x, "type": type(x).__name__}
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_freecad_code():
|
||||
"""Sample FreeCAD Python code."""
|
||||
return """
|
||||
import Part
|
||||
box = Part.makeBox(10, 20, 30)
|
||||
_result_ = {
|
||||
"volume": box.Volume,
|
||||
"area": box.Area,
|
||||
"is_valid": box.isValid(),
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_execution_result():
|
||||
"""Mock execution result for testing."""
|
||||
from freecad_mcp.bridge.base import ExecutionResult
|
||||
|
||||
return ExecutionResult(
|
||||
success=True,
|
||||
result={"value": 42},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=10.5,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_document_info():
|
||||
"""Mock document info for testing."""
|
||||
from freecad_mcp.bridge.base import DocumentInfo
|
||||
|
||||
return DocumentInfo(
|
||||
name="TestDoc",
|
||||
path="/tmp/test.FCStd",
|
||||
objects=["Box", "Cylinder"],
|
||||
is_modified=False,
|
||||
label="Test Document",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_object_info():
|
||||
"""Mock object info for testing."""
|
||||
from freecad_mcp.bridge.base import ObjectInfo
|
||||
|
||||
return ObjectInfo(
|
||||
name="Box",
|
||||
label="My Box",
|
||||
type_id="Part::Box",
|
||||
properties={"Length": 10.0, "Width": 20.0, "Height": 30.0},
|
||||
shape_info={
|
||||
"type": "Solid",
|
||||
"volume": 6000.0,
|
||||
"area": 2200.0,
|
||||
"is_valid": True,
|
||||
},
|
||||
children=[],
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration tests requiring FreeCAD installation."""
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Pytest configuration for integration tests.
|
||||
|
||||
This module handles connection checking and provides consolidated skip behavior
|
||||
when the FreeCAD MCP bridge is not available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
import xmlrpc.client
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
# Global flag to track bridge availability (checked once per session)
|
||||
_bridge_available: bool | None = None
|
||||
_bridge_error: str | None = None
|
||||
_warning_emitted: bool = False
|
||||
|
||||
|
||||
def _check_bridge_connection() -> tuple[bool, str | None]:
|
||||
"""Check if the FreeCAD MCP bridge is available.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_available, error_message)
|
||||
"""
|
||||
global _bridge_available, _bridge_error
|
||||
|
||||
if _bridge_available is not None:
|
||||
return _bridge_available, _bridge_error
|
||||
|
||||
try:
|
||||
proxy = xmlrpc.client.ServerProxy("http://localhost:9875", allow_none=True)
|
||||
result: dict[str, Any] = proxy.ping() # type: ignore[assignment]
|
||||
if result.get("pong"):
|
||||
_bridge_available = True
|
||||
_bridge_error = None
|
||||
else:
|
||||
_bridge_available = False
|
||||
_bridge_error = "FreeCAD MCP bridge not responding to ping"
|
||||
except ConnectionRefusedError:
|
||||
_bridge_available = False
|
||||
_bridge_error = "Connection refused - FreeCAD MCP bridge not running"
|
||||
except Exception as e:
|
||||
_bridge_available = False
|
||||
_bridge_error = f"Cannot connect to FreeCAD MCP bridge: {e}"
|
||||
|
||||
return _bridge_available, _bridge_error
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(
|
||||
_config: pytest.Config, items: list[pytest.Item]
|
||||
) -> None:
|
||||
"""Skip all integration tests if the bridge is not available.
|
||||
|
||||
This runs once during test collection and emits a single warning instead of
|
||||
per-test skip messages.
|
||||
"""
|
||||
global _warning_emitted
|
||||
|
||||
# Filter to only integration tests in this directory
|
||||
integration_tests = [
|
||||
item for item in items if "tests/integration" in str(item.fspath)
|
||||
]
|
||||
|
||||
if not integration_tests:
|
||||
return
|
||||
|
||||
# Check bridge connection once
|
||||
is_available, error = _check_bridge_connection()
|
||||
|
||||
if not is_available:
|
||||
# Apply skip marker to all integration tests
|
||||
skip_marker = pytest.mark.skip(reason="FreeCAD MCP bridge unavailable")
|
||||
for item in integration_tests:
|
||||
item.add_marker(skip_marker)
|
||||
|
||||
# Emit a single warning (only once)
|
||||
if not _warning_emitted:
|
||||
_warning_emitted = True
|
||||
warnings.warn(
|
||||
f"Skipping {len(integration_tests)} integration tests: {error}. "
|
||||
f"Start the bridge with 'just run-gui' or 'just run-headless'.",
|
||||
pytest.PytestWarning,
|
||||
stacklevel=1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def xmlrpc_proxy() -> xmlrpc.client.ServerProxy:
|
||||
"""Create XML-RPC proxy to FreeCAD MCP bridge.
|
||||
|
||||
This fixture is shared across all integration test modules.
|
||||
The connection check has already been performed during collection.
|
||||
"""
|
||||
is_available, error = _check_bridge_connection()
|
||||
if not is_available:
|
||||
pytest.skip(error or "FreeCAD MCP bridge not available")
|
||||
|
||||
return xmlrpc.client.ServerProxy("http://localhost:9875", allow_none=True)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,579 @@
|
||||
"""Integration tests for FreeCAD MCP GUI mode.
|
||||
|
||||
These tests verify that the MCP bridge works correctly when FreeCAD is running
|
||||
in GUI mode (with full graphical interface). They test GUI-specific features
|
||||
like visibility, display modes, colors, and camera operations.
|
||||
|
||||
Note: These tests require a running FreeCAD GUI server.
|
||||
Start it with: just run-gui
|
||||
|
||||
To run these tests:
|
||||
pytest tests/integration/test_gui_mode.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import xmlrpc.client
|
||||
from collections.abc import Generator
|
||||
|
||||
# Mark all tests in this module as integration tests and gui tests
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.gui]
|
||||
|
||||
# Note: xmlrpc_proxy fixture is defined in conftest.py
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def temp_dir() -> Generator[str, None, None]:
|
||||
"""Create a temporary directory for test files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield tmpdir
|
||||
|
||||
|
||||
def execute_code(proxy: xmlrpc.client.ServerProxy, code: str) -> dict[str, Any]:
|
||||
"""Execute Python code via the MCP bridge and return the result."""
|
||||
result: dict[str, Any] = proxy.execute(code) # type: ignore[assignment]
|
||||
assert result.get("success"), f"Execution failed: {result.get('error_traceback')}"
|
||||
return result
|
||||
|
||||
|
||||
class TestGUIConnection:
|
||||
"""Tests for GUI mode connectivity and detection."""
|
||||
|
||||
def test_ping(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test that the server responds to ping."""
|
||||
result: dict[str, Any] = xmlrpc_proxy.ping() # type: ignore[assignment]
|
||||
assert result["pong"] is True
|
||||
assert "timestamp" in result
|
||||
|
||||
def test_gui_mode_detected(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test that FreeCAD is running in GUI mode (GuiUp=True)."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
_result_ = {"gui_up": FreeCAD.GuiUp}
|
||||
""",
|
||||
)
|
||||
# In GUI mode, GuiUp should be True (1)
|
||||
assert result["result"]["gui_up"] == 1 or result["result"]["gui_up"] is True
|
||||
|
||||
|
||||
class TestGUIObjectCreation:
|
||||
"""Tests for object creation in GUI mode."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Create a fresh document for each test."""
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
# Close any existing GUITestDoc
|
||||
if "GUITestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("GUITestDoc")
|
||||
doc = FreeCAD.newDocument("GUITestDoc")
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
def test_create_box_with_view_object(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
||||
) -> None:
|
||||
"""Test creating a box with ViewObject in GUI mode."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
box = Part.makeBox(10, 20, 30)
|
||||
obj = doc.addObject("Part::Feature", "GUIBox")
|
||||
obj.Shape = box
|
||||
doc.recompute()
|
||||
|
||||
has_view_object = hasattr(obj, "ViewObject") and obj.ViewObject is not None
|
||||
|
||||
_result_ = {
|
||||
"name": obj.Name,
|
||||
"volume": obj.Shape.Volume,
|
||||
"has_view_object": has_view_object,
|
||||
"valid": obj.Shape.isValid()
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["name"] == "GUIBox"
|
||||
assert abs(result["result"]["volume"] - 6000.0) < 0.01
|
||||
assert result["result"]["has_view_object"] is True
|
||||
assert result["result"]["valid"] is True
|
||||
|
||||
|
||||
class TestVisibility:
|
||||
"""Tests for object visibility operations in GUI mode."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Create a document with test objects."""
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
if "VisibilityTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("VisibilityTestDoc")
|
||||
doc = FreeCAD.newDocument("VisibilityTestDoc")
|
||||
|
||||
box = Part.makeBox(10, 10, 10)
|
||||
obj = doc.addObject("Part::Feature", "VisBox")
|
||||
obj.Shape = box
|
||||
doc.recompute()
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
def test_hide_object(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test hiding an object."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
obj = doc.getObject("VisBox")
|
||||
|
||||
# Hide the object
|
||||
obj.ViewObject.Visibility = False
|
||||
|
||||
_result_ = {
|
||||
"visible": obj.ViewObject.Visibility
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["visible"] is False
|
||||
|
||||
def test_show_object(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test showing an object."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
obj = doc.getObject("VisBox")
|
||||
|
||||
# First hide, then show
|
||||
obj.ViewObject.Visibility = False
|
||||
obj.ViewObject.Visibility = True
|
||||
|
||||
_result_ = {
|
||||
"visible": obj.ViewObject.Visibility
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["visible"] is True
|
||||
|
||||
|
||||
class TestDisplayMode:
|
||||
"""Tests for display mode operations in GUI mode."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Create a document with test objects."""
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
if "DisplayModeTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("DisplayModeTestDoc")
|
||||
doc = FreeCAD.newDocument("DisplayModeTestDoc")
|
||||
|
||||
box = Part.makeBox(10, 10, 10)
|
||||
obj = doc.addObject("Part::Feature", "DisplayBox")
|
||||
obj.Shape = box
|
||||
doc.recompute()
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
def test_get_display_modes(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test getting available display modes."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
obj = doc.getObject("DisplayBox")
|
||||
|
||||
modes = obj.ViewObject.listDisplayModes()
|
||||
|
||||
_result_ = {
|
||||
"modes": list(modes),
|
||||
"current": obj.ViewObject.DisplayMode
|
||||
}
|
||||
""",
|
||||
)
|
||||
# Typical modes: "Flat Lines", "Shaded", "Wireframe", etc.
|
||||
assert len(result["result"]["modes"]) > 0
|
||||
assert result["result"]["current"] is not None
|
||||
|
||||
def test_set_display_mode(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test setting display mode."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
obj = doc.getObject("DisplayBox")
|
||||
|
||||
modes = obj.ViewObject.listDisplayModes()
|
||||
# Set to a different mode if available
|
||||
target_mode = "Wireframe" if "Wireframe" in modes else modes[0]
|
||||
obj.ViewObject.DisplayMode = target_mode
|
||||
|
||||
_result_ = {
|
||||
"mode_set": obj.ViewObject.DisplayMode,
|
||||
"target_mode": target_mode
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["mode_set"] == result["result"]["target_mode"]
|
||||
|
||||
|
||||
class TestObjectColor:
|
||||
"""Tests for object color operations in GUI mode."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Create a document with test objects."""
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
if "ColorTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("ColorTestDoc")
|
||||
doc = FreeCAD.newDocument("ColorTestDoc")
|
||||
|
||||
box = Part.makeBox(10, 10, 10)
|
||||
obj = doc.addObject("Part::Feature", "ColorBox")
|
||||
obj.Shape = box
|
||||
doc.recompute()
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
def test_set_shape_color(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test setting object shape color."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
obj = doc.getObject("ColorBox")
|
||||
|
||||
# Set color to red (RGB as tuple of floats 0-1)
|
||||
obj.ViewObject.ShapeColor = (1.0, 0.0, 0.0)
|
||||
|
||||
color = obj.ViewObject.ShapeColor
|
||||
|
||||
_result_ = {
|
||||
"r": color[0],
|
||||
"g": color[1],
|
||||
"b": color[2]
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["r"] == pytest.approx(1.0, rel=0.01)
|
||||
assert result["result"]["g"] == pytest.approx(0.0, abs=0.01)
|
||||
assert result["result"]["b"] == pytest.approx(0.0, abs=0.01)
|
||||
|
||||
def test_set_transparency(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test setting object transparency."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
obj = doc.getObject("ColorBox")
|
||||
|
||||
# Set 50% transparency
|
||||
obj.ViewObject.Transparency = 50
|
||||
|
||||
_result_ = {
|
||||
"transparency": obj.ViewObject.Transparency
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["transparency"] == 50
|
||||
|
||||
|
||||
class TestCameraOperations:
|
||||
"""Tests for camera/view operations in GUI mode."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Create a document with test objects."""
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
if "CameraTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("CameraTestDoc")
|
||||
doc = FreeCAD.newDocument("CameraTestDoc")
|
||||
|
||||
box = Part.makeBox(10, 10, 10)
|
||||
obj = doc.addObject("Part::Feature", "CameraBox")
|
||||
obj.Shape = box
|
||||
doc.recompute()
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
def test_fit_all(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test fit all view operation."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import FreeCADGui
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
view = FreeCADGui.ActiveDocument.ActiveView
|
||||
|
||||
# Fit all objects in view
|
||||
view.fitAll()
|
||||
|
||||
_result_ = {"success": True}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["success"] is True
|
||||
|
||||
def test_set_view_direction(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test setting view direction (front, top, etc.)."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import FreeCADGui
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
view = FreeCADGui.ActiveDocument.ActiveView
|
||||
|
||||
# Set to front view
|
||||
view.viewFront()
|
||||
|
||||
# Get camera orientation
|
||||
cam = view.getCameraOrientation()
|
||||
|
||||
_result_ = {
|
||||
"success": True,
|
||||
"camera_orientation": [cam.Q[0], cam.Q[1], cam.Q[2], cam.Q[3]]
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["success"] is True
|
||||
|
||||
|
||||
class TestScreenshot:
|
||||
"""Tests for screenshot functionality in GUI mode."""
|
||||
|
||||
def test_capture_screenshot(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
|
||||
) -> None:
|
||||
"""Test capturing a screenshot."""
|
||||
# First create a document with objects
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
if "ScreenshotTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("ScreenshotTestDoc")
|
||||
doc = FreeCAD.newDocument("ScreenshotTestDoc")
|
||||
|
||||
box = Part.makeBox(20, 20, 20)
|
||||
obj = doc.addObject("Part::Feature", "ScreenshotBox")
|
||||
obj.Shape = box
|
||||
obj.ViewObject.ShapeColor = (0.0, 0.5, 1.0) # Blue color
|
||||
doc.recompute()
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
screenshot_path = Path(temp_dir) / "test_screenshot.png"
|
||||
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
f"""
|
||||
import FreeCAD
|
||||
import FreeCADGui
|
||||
import os
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
view = FreeCADGui.ActiveDocument.ActiveView
|
||||
|
||||
# Fit objects in view
|
||||
view.fitAll()
|
||||
|
||||
# Save screenshot
|
||||
view.saveImage({screenshot_path!r}, 800, 600, "White")
|
||||
|
||||
_result_ = {{
|
||||
"saved": os.path.exists({screenshot_path!r}),
|
||||
"path": {screenshot_path!r}
|
||||
}}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["saved"] is True
|
||||
assert screenshot_path.exists()
|
||||
# Verify file has content
|
||||
assert screenshot_path.stat().st_size > 0
|
||||
|
||||
|
||||
class TestComplexGUIWorkflow:
|
||||
"""Tests for complex workflows that combine GUI and modeling features."""
|
||||
|
||||
def test_create_assembly_with_colors(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
||||
) -> None:
|
||||
"""Test creating multiple objects with different colors."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
if "AssemblyTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("AssemblyTestDoc")
|
||||
doc = FreeCAD.newDocument("AssemblyTestDoc")
|
||||
|
||||
# Create base plate (gray)
|
||||
base = Part.makeBox(100, 100, 5)
|
||||
base_obj = doc.addObject("Part::Feature", "BasePlate")
|
||||
base_obj.Shape = base
|
||||
base_obj.ViewObject.ShapeColor = (0.5, 0.5, 0.5)
|
||||
|
||||
# Create pillar 1 (red)
|
||||
pillar1 = Part.makeBox(10, 10, 50, FreeCAD.Vector(10, 10, 5))
|
||||
pillar1_obj = doc.addObject("Part::Feature", "Pillar1")
|
||||
pillar1_obj.Shape = pillar1
|
||||
pillar1_obj.ViewObject.ShapeColor = (1.0, 0.0, 0.0)
|
||||
|
||||
# Create pillar 2 (green)
|
||||
pillar2 = Part.makeBox(10, 10, 50, FreeCAD.Vector(80, 10, 5))
|
||||
pillar2_obj = doc.addObject("Part::Feature", "Pillar2")
|
||||
pillar2_obj.Shape = pillar2
|
||||
pillar2_obj.ViewObject.ShapeColor = (0.0, 1.0, 0.0)
|
||||
|
||||
# Create pillar 3 (blue)
|
||||
pillar3 = Part.makeBox(10, 10, 50, FreeCAD.Vector(80, 80, 5))
|
||||
pillar3_obj = doc.addObject("Part::Feature", "Pillar3")
|
||||
pillar3_obj.Shape = pillar3
|
||||
pillar3_obj.ViewObject.ShapeColor = (0.0, 0.0, 1.0)
|
||||
|
||||
# Create pillar 4 (yellow)
|
||||
pillar4 = Part.makeBox(10, 10, 50, FreeCAD.Vector(10, 80, 5))
|
||||
pillar4_obj = doc.addObject("Part::Feature", "Pillar4")
|
||||
pillar4_obj.Shape = pillar4
|
||||
pillar4_obj.ViewObject.ShapeColor = (1.0, 1.0, 0.0)
|
||||
|
||||
# Create top plate (semi-transparent gray)
|
||||
top = Part.makeBox(100, 100, 5, FreeCAD.Vector(0, 0, 55))
|
||||
top_obj = doc.addObject("Part::Feature", "TopPlate")
|
||||
top_obj.Shape = top
|
||||
top_obj.ViewObject.ShapeColor = (0.5, 0.5, 0.5)
|
||||
top_obj.ViewObject.Transparency = 30
|
||||
|
||||
doc.recompute()
|
||||
|
||||
_result_ = {
|
||||
"object_count": len(doc.Objects),
|
||||
"objects": [obj.Name for obj in doc.Objects],
|
||||
"all_visible": all(obj.ViewObject.Visibility for obj in doc.Objects)
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["object_count"] == 6
|
||||
assert "BasePlate" in result["result"]["objects"]
|
||||
assert "TopPlate" in result["result"]["objects"]
|
||||
assert result["result"]["all_visible"] is True
|
||||
|
||||
def test_export_with_screenshot(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
|
||||
) -> None:
|
||||
"""Test creating model, setting view, taking screenshot, and exporting."""
|
||||
step_path = Path(temp_dir) / "workflow_export.step"
|
||||
screenshot_path = Path(temp_dir) / "workflow_screenshot.png"
|
||||
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
f"""
|
||||
import FreeCAD
|
||||
import FreeCADGui
|
||||
import Part
|
||||
import os
|
||||
|
||||
if "WorkflowTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("WorkflowTestDoc")
|
||||
doc = FreeCAD.newDocument("WorkflowTestDoc")
|
||||
|
||||
# Create a simple bracket shape
|
||||
# Base
|
||||
base = Part.makeBox(50, 30, 5)
|
||||
# Vertical support
|
||||
vertical = Part.makeBox(5, 30, 40, FreeCAD.Vector(0, 0, 5))
|
||||
# Top flange with hole
|
||||
top_flange = Part.makeBox(20, 30, 5, FreeCAD.Vector(0, 0, 45))
|
||||
hole = Part.makeCylinder(4, 10, FreeCAD.Vector(10, 15, 40))
|
||||
|
||||
# Combine and cut
|
||||
bracket = base.fuse(vertical).fuse(top_flange).cut(hole)
|
||||
bracket_obj = doc.addObject("Part::Feature", "Bracket")
|
||||
bracket_obj.Shape = bracket
|
||||
bracket_obj.ViewObject.ShapeColor = (0.2, 0.4, 0.8)
|
||||
|
||||
doc.recompute()
|
||||
|
||||
# Set isometric view
|
||||
view = FreeCADGui.ActiveDocument.ActiveView
|
||||
view.viewIsometric()
|
||||
view.fitAll()
|
||||
|
||||
# Take screenshot
|
||||
view.saveImage({screenshot_path!r}, 800, 600, "White")
|
||||
|
||||
# Export to STEP
|
||||
bracket_obj.Shape.exportStep({step_path!r})
|
||||
|
||||
_result_ = {{
|
||||
"bracket_valid": bracket_obj.Shape.isValid(),
|
||||
"bracket_volume": bracket_obj.Shape.Volume,
|
||||
"screenshot_exists": os.path.exists({screenshot_path!r}),
|
||||
"step_exists": os.path.exists({step_path!r})
|
||||
}}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["bracket_valid"] is True
|
||||
assert result["result"]["bracket_volume"] > 0
|
||||
assert result["result"]["screenshot_exists"] is True
|
||||
assert result["result"]["step_exists"] is True
|
||||
assert screenshot_path.exists()
|
||||
assert step_path.exists()
|
||||
@@ -0,0 +1,624 @@
|
||||
"""Integration tests for FreeCAD MCP headless mode.
|
||||
|
||||
These tests verify that the MCP bridge works correctly when FreeCAD is running
|
||||
in headless mode (without GUI). They test object creation, manipulation, and
|
||||
export functionality.
|
||||
|
||||
Note: These tests require a running FreeCAD headless server.
|
||||
Start it with: just run-headless
|
||||
|
||||
To run these tests:
|
||||
pytest tests/integration/test_headless_mode.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import xmlrpc.client
|
||||
from collections.abc import Generator
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
# Note: xmlrpc_proxy fixture is defined in conftest.py
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def temp_dir() -> Generator[str, None, None]:
|
||||
"""Create a temporary directory for test files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield tmpdir
|
||||
|
||||
|
||||
def execute_code(proxy: xmlrpc.client.ServerProxy, code: str) -> dict[str, Any]:
|
||||
"""Execute Python code via the MCP bridge and return the result."""
|
||||
result: dict[str, Any] = proxy.execute(code) # type: ignore[assignment]
|
||||
assert result.get("success"), f"Execution failed: {result.get('error_traceback')}"
|
||||
return result
|
||||
|
||||
|
||||
class TestHeadlessConnection:
|
||||
"""Tests for basic headless mode connectivity."""
|
||||
|
||||
def test_ping(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test that the server responds to ping."""
|
||||
result: dict[str, Any] = xmlrpc_proxy.ping() # type: ignore[assignment]
|
||||
assert result["pong"] is True
|
||||
assert "timestamp" in result
|
||||
|
||||
def test_headless_mode_detected(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
||||
) -> None:
|
||||
"""Test that FreeCAD is running in headless mode (GuiUp=False)."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
_result_ = {"gui_up": FreeCAD.GuiUp}
|
||||
""",
|
||||
)
|
||||
# In headless mode, GuiUp should be False (0)
|
||||
assert result["result"]["gui_up"] == 0 or result["result"]["gui_up"] is False
|
||||
|
||||
|
||||
class TestDocumentManagement:
|
||||
"""Tests for document creation and management in headless mode."""
|
||||
|
||||
def test_create_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test creating a new document."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
doc = FreeCAD.newDocument("TestDoc")
|
||||
_result_ = {"name": doc.Name, "object_count": len(doc.Objects)}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["name"] == "TestDoc"
|
||||
assert result["result"]["object_count"] == 0
|
||||
|
||||
def test_list_documents(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test listing open documents."""
|
||||
# First create a document
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
if not FreeCAD.listDocuments():
|
||||
FreeCAD.newDocument("ListTestDoc")
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
docs = list(FreeCAD.listDocuments().keys())
|
||||
_result_ = {"documents": docs, "count": len(docs)}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["count"] >= 1
|
||||
|
||||
def test_close_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test closing a document."""
|
||||
# Create a document to close
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
doc = FreeCAD.newDocument("ToClose")
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
FreeCAD.closeDocument("ToClose")
|
||||
_result_ = {"closed": "ToClose" not in FreeCAD.listDocuments()}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["closed"] is True
|
||||
|
||||
|
||||
class TestPrimitiveCreation:
|
||||
"""Tests for creating primitive shapes in headless mode."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Create a fresh document for each test."""
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
# Close any existing PrimitiveTestDoc
|
||||
if "PrimitiveTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("PrimitiveTestDoc")
|
||||
doc = FreeCAD.newDocument("PrimitiveTestDoc")
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
def test_create_box(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test creating a Part::Box primitive."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
box = Part.makeBox(10, 20, 30)
|
||||
obj = doc.addObject("Part::Feature", "TestBox")
|
||||
obj.Shape = box
|
||||
doc.recompute()
|
||||
|
||||
_result_ = {
|
||||
"name": obj.Name,
|
||||
"volume": obj.Shape.Volume,
|
||||
"valid": obj.Shape.isValid()
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["name"] == "TestBox"
|
||||
# Volume should be 10 * 20 * 30 = 6000
|
||||
assert abs(result["result"]["volume"] - 6000.0) < 0.01
|
||||
assert result["result"]["valid"] is True
|
||||
|
||||
def test_create_cylinder(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test creating a Part::Cylinder primitive."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
import math
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
cylinder = Part.makeCylinder(5, 20)
|
||||
obj = doc.addObject("Part::Feature", "TestCylinder")
|
||||
obj.Shape = cylinder
|
||||
doc.recompute()
|
||||
|
||||
expected_volume = math.pi * 5**2 * 20
|
||||
|
||||
_result_ = {
|
||||
"name": obj.Name,
|
||||
"volume": obj.Shape.Volume,
|
||||
"expected_volume": expected_volume,
|
||||
"valid": obj.Shape.isValid()
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["name"] == "TestCylinder"
|
||||
# Volume should be π * r² * h = π * 25 * 20 ≈ 1570.8
|
||||
assert (
|
||||
abs(result["result"]["volume"] - result["result"]["expected_volume"]) < 0.1
|
||||
)
|
||||
assert result["result"]["valid"] is True
|
||||
|
||||
def test_create_sphere(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test creating a Part::Sphere primitive."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
import math
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
sphere = Part.makeSphere(10)
|
||||
obj = doc.addObject("Part::Feature", "TestSphere")
|
||||
obj.Shape = sphere
|
||||
doc.recompute()
|
||||
|
||||
expected_volume = (4/3) * math.pi * 10**3
|
||||
|
||||
_result_ = {
|
||||
"name": obj.Name,
|
||||
"volume": obj.Shape.Volume,
|
||||
"expected_volume": expected_volume,
|
||||
"valid": obj.Shape.isValid()
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["name"] == "TestSphere"
|
||||
# Volume should be (4/3) * π * r³ ≈ 4188.79
|
||||
assert (
|
||||
abs(result["result"]["volume"] - result["result"]["expected_volume"]) < 1.0
|
||||
)
|
||||
assert result["result"]["valid"] is True
|
||||
|
||||
def test_create_cone(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test creating a Part::Cone primitive."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
cone = Part.makeCone(10, 0, 20) # Base radius=10, top radius=0, height=20
|
||||
obj = doc.addObject("Part::Feature", "TestCone")
|
||||
obj.Shape = cone
|
||||
doc.recompute()
|
||||
|
||||
_result_ = {
|
||||
"name": obj.Name,
|
||||
"volume": obj.Shape.Volume,
|
||||
"valid": obj.Shape.isValid()
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["name"] == "TestCone"
|
||||
# Volume of a cone = (1/3) * π * r² * h ≈ 2094.4
|
||||
assert result["result"]["volume"] > 2000
|
||||
assert result["result"]["valid"] is True
|
||||
|
||||
def test_create_torus(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test creating a Part::Torus primitive."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
torus = Part.makeTorus(20, 5) # Major radius=20, minor radius=5
|
||||
obj = doc.addObject("Part::Feature", "TestTorus")
|
||||
obj.Shape = torus
|
||||
doc.recompute()
|
||||
|
||||
_result_ = {
|
||||
"name": obj.Name,
|
||||
"volume": obj.Shape.Volume,
|
||||
"valid": obj.Shape.isValid()
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["name"] == "TestTorus"
|
||||
assert result["result"]["volume"] > 0
|
||||
assert result["result"]["valid"] is True
|
||||
|
||||
|
||||
class TestBooleanOperations:
|
||||
"""Tests for boolean operations in headless mode."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Create a fresh document for each test."""
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
if "BooleanTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("BooleanTestDoc")
|
||||
doc = FreeCAD.newDocument("BooleanTestDoc")
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
def test_boolean_fuse(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test boolean fuse (union) operation."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
|
||||
# Create two overlapping boxes
|
||||
box1 = Part.makeBox(10, 10, 10)
|
||||
box2 = Part.makeBox(10, 10, 10, FreeCAD.Vector(5, 0, 0))
|
||||
|
||||
obj1 = doc.addObject("Part::Feature", "Box1")
|
||||
obj1.Shape = box1
|
||||
obj2 = doc.addObject("Part::Feature", "Box2")
|
||||
obj2.Shape = box2
|
||||
|
||||
# Fuse them
|
||||
fused = box1.fuse(box2)
|
||||
obj_fused = doc.addObject("Part::Feature", "Fused")
|
||||
obj_fused.Shape = fused
|
||||
doc.recompute()
|
||||
|
||||
# Fused volume should be less than 2000 (two boxes) due to overlap
|
||||
_result_ = {
|
||||
"fused_volume": obj_fused.Shape.Volume,
|
||||
"valid": obj_fused.Shape.isValid()
|
||||
}
|
||||
""",
|
||||
)
|
||||
# Two 10x10x10 boxes overlapping by 5mm = 2000 - 500 = 1500
|
||||
assert result["result"]["fused_volume"] == pytest.approx(1500.0, rel=0.01)
|
||||
assert result["result"]["valid"] is True
|
||||
|
||||
def test_boolean_cut(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test boolean cut (subtract) operation."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
|
||||
# Create a box and a cylinder to cut from it
|
||||
box = Part.makeBox(20, 20, 20)
|
||||
cylinder = Part.makeCylinder(5, 30, FreeCAD.Vector(10, 10, -5))
|
||||
|
||||
# Cut cylinder from box
|
||||
cut = box.cut(cylinder)
|
||||
obj_cut = doc.addObject("Part::Feature", "Cut")
|
||||
obj_cut.Shape = cut
|
||||
doc.recompute()
|
||||
|
||||
box_volume = 20 * 20 * 20
|
||||
import math
|
||||
cylinder_volume_in_box = math.pi * 5**2 * 20 # Only 20mm of cylinder is in box
|
||||
|
||||
_result_ = {
|
||||
"cut_volume": obj_cut.Shape.Volume,
|
||||
"expected_volume": box_volume - cylinder_volume_in_box,
|
||||
"valid": obj_cut.Shape.isValid()
|
||||
}
|
||||
""",
|
||||
)
|
||||
# Volume should be box - cylinder intersection
|
||||
assert (
|
||||
abs(result["result"]["cut_volume"] - result["result"]["expected_volume"])
|
||||
< 10
|
||||
)
|
||||
assert result["result"]["valid"] is True
|
||||
|
||||
|
||||
class TestObjectManipulation:
|
||||
"""Tests for object manipulation in headless mode."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Create a fresh document with a test box."""
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
if "ManipTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("ManipTestDoc")
|
||||
doc = FreeCAD.newDocument("ManipTestDoc")
|
||||
|
||||
box = Part.makeBox(10, 10, 10)
|
||||
obj = doc.addObject("Part::Feature", "ManipBox")
|
||||
obj.Shape = box
|
||||
doc.recompute()
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
def test_set_placement_position(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
||||
) -> None:
|
||||
"""Test setting object position."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
obj = doc.getObject("ManipBox")
|
||||
|
||||
# Move the object
|
||||
obj.Placement.Base = FreeCAD.Vector(100, 200, 300)
|
||||
|
||||
_result_ = {
|
||||
"x": obj.Placement.Base.x,
|
||||
"y": obj.Placement.Base.y,
|
||||
"z": obj.Placement.Base.z
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["x"] == 100.0
|
||||
assert result["result"]["y"] == 200.0
|
||||
assert result["result"]["z"] == 300.0
|
||||
|
||||
def test_set_placement_rotation(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
||||
) -> None:
|
||||
"""Test setting object rotation."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
obj = doc.getObject("ManipBox")
|
||||
|
||||
# Rotate the object 45 degrees around Z axis
|
||||
obj.Placement.Rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), 45)
|
||||
doc.recompute()
|
||||
|
||||
euler = obj.Placement.Rotation.toEuler()
|
||||
_result_ = {
|
||||
"rotation_z": euler[0], # Yaw
|
||||
"valid": obj.Shape.isValid()
|
||||
}
|
||||
""",
|
||||
)
|
||||
# Check rotation is approximately 45 degrees
|
||||
assert abs(result["result"]["rotation_z"] - 45.0) < 0.1
|
||||
assert result["result"]["valid"] is True
|
||||
|
||||
|
||||
class TestExportOperations:
|
||||
"""Tests for export functionality in headless mode."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_document(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
|
||||
) -> None:
|
||||
"""Create a document with objects for export tests."""
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
if "ExportTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("ExportTestDoc")
|
||||
doc = FreeCAD.newDocument("ExportTestDoc")
|
||||
|
||||
# Create a simple box
|
||||
box = Part.makeBox(10, 10, 10)
|
||||
obj = doc.addObject("Part::Feature", "ExportBox")
|
||||
obj.Shape = box
|
||||
doc.recompute()
|
||||
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
self.temp_dir = temp_dir
|
||||
|
||||
def test_export_step(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
|
||||
) -> None:
|
||||
"""Test exporting to STEP format."""
|
||||
step_path = Path(temp_dir) / "test_export.step"
|
||||
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
f"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
obj = doc.getObject("ExportBox")
|
||||
obj.Shape.exportStep({step_path!r})
|
||||
|
||||
import os
|
||||
_result_ = {{"exported": os.path.exists({step_path!r})}}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["exported"] is True
|
||||
assert step_path.exists()
|
||||
|
||||
def test_save_fcstd(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
|
||||
) -> None:
|
||||
"""Test saving as FreeCAD native format."""
|
||||
fcstd_path = Path(temp_dir) / "test_save.FCStd"
|
||||
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
f"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
doc.saveAs({fcstd_path!r})
|
||||
|
||||
import os
|
||||
_result_ = {{
|
||||
"saved": os.path.exists({fcstd_path!r}),
|
||||
"path": {fcstd_path!r}
|
||||
}}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["saved"] is True
|
||||
assert fcstd_path.exists()
|
||||
|
||||
|
||||
class TestGUIFeaturesInHeadless:
|
||||
"""Tests to verify GUI features fail gracefully in headless mode."""
|
||||
|
||||
def test_gui_features_not_available(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
||||
) -> None:
|
||||
"""Test that GUI-only features return appropriate errors."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
|
||||
gui_available = FreeCAD.GuiUp
|
||||
|
||||
if not gui_available:
|
||||
_result_ = {
|
||||
"success": False,
|
||||
"error": "GUI not available in headless mode",
|
||||
"gui_up": False
|
||||
}
|
||||
else:
|
||||
_result_ = {
|
||||
"success": True,
|
||||
"gui_up": True
|
||||
}
|
||||
""",
|
||||
)
|
||||
# In headless mode, GUI should not be available
|
||||
assert result["result"]["gui_up"] is False
|
||||
|
||||
|
||||
class TestComplexWorkflow:
|
||||
"""Tests for complex multi-step workflows in headless mode."""
|
||||
|
||||
def test_parametric_modeling_workflow(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
||||
) -> None:
|
||||
"""Test a complete parametric modeling workflow."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
# Create document
|
||||
if "WorkflowTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("WorkflowTestDoc")
|
||||
doc = FreeCAD.newDocument("WorkflowTestDoc")
|
||||
|
||||
# Step 1: Create base box
|
||||
base_box = Part.makeBox(50, 50, 10)
|
||||
base_obj = doc.addObject("Part::Feature", "Base")
|
||||
base_obj.Shape = base_box
|
||||
|
||||
# Step 2: Create cylinder to cut
|
||||
cylinder = Part.makeCylinder(5, 20, FreeCAD.Vector(25, 25, -5))
|
||||
|
||||
# Step 3: Cut hole in base
|
||||
result_shape = base_box.cut(cylinder)
|
||||
final_obj = doc.addObject("Part::Feature", "BaseWithHole")
|
||||
final_obj.Shape = result_shape
|
||||
|
||||
# Step 4: Add fillet (rounded edges) - using Part API
|
||||
# Select edges and apply fillet
|
||||
try:
|
||||
filleted = final_obj.Shape.makeFillet(2, final_obj.Shape.Edges[:4])
|
||||
fillet_obj = doc.addObject("Part::Feature", "Filleted")
|
||||
fillet_obj.Shape = filleted
|
||||
fillet_success = True
|
||||
except Exception:
|
||||
fillet_success = False
|
||||
|
||||
doc.recompute()
|
||||
|
||||
_result_ = {
|
||||
"objects": [obj.Name for obj in doc.Objects],
|
||||
"final_volume": final_obj.Shape.Volume,
|
||||
"final_valid": final_obj.Shape.isValid(),
|
||||
"fillet_success": fillet_success
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert "Base" in result["result"]["objects"]
|
||||
assert "BaseWithHole" in result["result"]["objects"]
|
||||
assert result["result"]["final_valid"] is True
|
||||
# Volume should be base - cylinder hole
|
||||
assert result["result"]["final_volume"] > 0
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests for FreeCAD MCP Server."""
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Tests for bridge base classes."""
|
||||
|
||||
import pytest
|
||||
|
||||
from freecad_mcp.bridge.base import (
|
||||
DocumentInfo,
|
||||
ExecutionResult,
|
||||
FreecadBridge,
|
||||
ObjectInfo,
|
||||
)
|
||||
|
||||
|
||||
class TestExecutionResult:
|
||||
"""Tests for ExecutionResult dataclass."""
|
||||
|
||||
def test_successful_result(self):
|
||||
"""Successful execution result should have correct attributes."""
|
||||
result = ExecutionResult(
|
||||
success=True,
|
||||
result={"value": 42},
|
||||
stdout="output",
|
||||
stderr="",
|
||||
execution_time_ms=10.5,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.result == {"value": 42}
|
||||
assert result.stdout == "output"
|
||||
assert result.stderr == ""
|
||||
assert result.execution_time_ms == 10.5
|
||||
assert result.error_type is None
|
||||
assert result.error_traceback is None
|
||||
|
||||
def test_failed_result(self):
|
||||
"""Failed execution result should include error details."""
|
||||
result = ExecutionResult(
|
||||
success=False,
|
||||
result=None,
|
||||
stdout="",
|
||||
stderr="error occurred",
|
||||
execution_time_ms=5.0,
|
||||
error_type="ValueError",
|
||||
error_traceback="Traceback...",
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.result is None
|
||||
assert result.error_type == "ValueError"
|
||||
assert result.error_traceback == "Traceback..."
|
||||
|
||||
|
||||
class TestDocumentInfo:
|
||||
"""Tests for DocumentInfo dataclass."""
|
||||
|
||||
def test_document_with_all_fields(self):
|
||||
"""DocumentInfo should store all fields correctly."""
|
||||
doc = DocumentInfo(
|
||||
name="TestDoc",
|
||||
path="/path/to/doc.FCStd",
|
||||
objects=["Box", "Cylinder"],
|
||||
is_modified=True,
|
||||
label="Test Document",
|
||||
)
|
||||
|
||||
assert doc.name == "TestDoc"
|
||||
assert doc.path == "/path/to/doc.FCStd"
|
||||
assert doc.objects == ["Box", "Cylinder"]
|
||||
assert doc.is_modified is True
|
||||
assert doc.label == "Test Document"
|
||||
|
||||
def test_document_with_defaults(self):
|
||||
"""DocumentInfo should use sensible defaults."""
|
||||
doc = DocumentInfo(name="Doc", path=None)
|
||||
|
||||
assert doc.name == "Doc"
|
||||
assert doc.path is None
|
||||
assert doc.objects == []
|
||||
assert doc.is_modified is False
|
||||
assert doc.label == "Doc" # Label defaults to name
|
||||
|
||||
|
||||
class TestObjectInfo:
|
||||
"""Tests for ObjectInfo dataclass."""
|
||||
|
||||
def test_object_with_shape(self):
|
||||
"""ObjectInfo should store shape information correctly."""
|
||||
obj = ObjectInfo(
|
||||
name="Box",
|
||||
label="My Box",
|
||||
type_id="Part::Box",
|
||||
properties={"Length": 10.0},
|
||||
shape_info={"type": "Solid", "volume": 1000.0},
|
||||
children=["Child1"],
|
||||
)
|
||||
|
||||
assert obj.name == "Box"
|
||||
assert obj.label == "My Box"
|
||||
assert obj.type_id == "Part::Box"
|
||||
assert obj.properties == {"Length": 10.0}
|
||||
assert obj.shape_info == {"type": "Solid", "volume": 1000.0}
|
||||
assert obj.children == ["Child1"]
|
||||
|
||||
def test_object_with_defaults(self):
|
||||
"""ObjectInfo should use sensible defaults."""
|
||||
obj = ObjectInfo(name="Obj", label="Obj", type_id="Part::Feature")
|
||||
|
||||
assert obj.properties == {}
|
||||
assert obj.shape_info is None
|
||||
assert obj.children == []
|
||||
|
||||
|
||||
class TestFreecadBridgeInterface:
|
||||
"""Tests for FreecadBridge abstract interface."""
|
||||
|
||||
def test_cannot_instantiate_abstract_class(self):
|
||||
"""FreecadBridge should not be instantiable directly."""
|
||||
with pytest.raises(TypeError):
|
||||
FreecadBridge() # type: ignore[abstract]
|
||||
|
||||
def test_subclass_must_implement_methods(self):
|
||||
"""Subclass must implement all abstract methods."""
|
||||
|
||||
class IncompleteBridge(FreecadBridge):
|
||||
pass
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
IncompleteBridge() # type: ignore[abstract]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Tests for configuration module."""
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from freecad_mcp.config import FreecadMode, ServerConfig, TransportType, get_config
|
||||
|
||||
|
||||
class TestServerConfig:
|
||||
"""Tests for ServerConfig class."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Default configuration should use sensible defaults."""
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
config = ServerConfig()
|
||||
|
||||
assert config.mode == FreecadMode.EMBEDDED
|
||||
assert config.socket_host == "localhost"
|
||||
assert config.socket_port == 9876
|
||||
assert config.timeout_ms == 30000
|
||||
assert config.max_output_size == 1_000_000
|
||||
assert config.transport == TransportType.STDIO
|
||||
assert config.http_port == 8000
|
||||
assert config.log_level == "INFO"
|
||||
assert config.enable_sandbox is True
|
||||
|
||||
def test_socket_mode_from_env(self):
|
||||
"""Configuration should read mode from environment."""
|
||||
with mock.patch.dict(os.environ, {"FREECAD_MODE": "socket"}):
|
||||
config = ServerConfig()
|
||||
|
||||
assert config.mode == FreecadMode.SOCKET
|
||||
|
||||
def test_custom_port_from_env(self):
|
||||
"""Configuration should read port from environment."""
|
||||
with mock.patch.dict(os.environ, {"FREECAD_SOCKET_PORT": "12345"}):
|
||||
config = ServerConfig()
|
||||
|
||||
assert config.socket_port == 12345
|
||||
|
||||
def test_http_transport_from_env(self):
|
||||
"""Configuration should read transport from environment."""
|
||||
with mock.patch.dict(os.environ, {"FREECAD_TRANSPORT": "http"}):
|
||||
config = ServerConfig()
|
||||
|
||||
assert config.transport == TransportType.HTTP
|
||||
|
||||
def test_invalid_port_raises_error(self):
|
||||
"""Invalid port should raise validation error."""
|
||||
with mock.patch.dict(os.environ, {"FREECAD_SOCKET_PORT": "99999"}):
|
||||
with pytest.raises(Exception): # Pydantic validation error
|
||||
ServerConfig()
|
||||
|
||||
def test_get_config_returns_instance(self):
|
||||
"""get_config should return a ServerConfig instance."""
|
||||
config = get_config()
|
||||
|
||||
assert isinstance(config, ServerConfig)
|
||||
|
||||
|
||||
class TestFreecadMode:
|
||||
"""Tests for FreecadMode enum."""
|
||||
|
||||
def test_embedded_value(self):
|
||||
"""EMBEDDED should have correct string value."""
|
||||
assert FreecadMode.EMBEDDED.value == "embedded"
|
||||
|
||||
def test_socket_value(self):
|
||||
"""SOCKET should have correct string value."""
|
||||
assert FreecadMode.SOCKET.value == "socket"
|
||||
|
||||
|
||||
class TestTransportType:
|
||||
"""Tests for TransportType enum."""
|
||||
|
||||
def test_stdio_value(self):
|
||||
"""STDIO should have correct string value."""
|
||||
assert TransportType.STDIO.value == "stdio"
|
||||
|
||||
def test_http_value(self):
|
||||
"""HTTP should have correct string value."""
|
||||
assert TransportType.HTTP.value == "http"
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Tests for embedded bridge implementation."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from freecad_mcp.bridge.embedded import EmbeddedBridge
|
||||
|
||||
|
||||
class TestEmbeddedBridge:
|
||||
"""Tests for EmbeddedBridge class."""
|
||||
|
||||
def test_initialization(self):
|
||||
"""Bridge should initialize with correct defaults."""
|
||||
bridge = EmbeddedBridge()
|
||||
|
||||
assert bridge._freecad_path is None
|
||||
assert bridge._fc_module is None
|
||||
assert bridge._connected is False
|
||||
|
||||
def test_initialization_with_path(self):
|
||||
"""Bridge should accept custom FreeCAD path."""
|
||||
bridge = EmbeddedBridge(freecad_path="/custom/path")
|
||||
|
||||
assert bridge._freecad_path == "/custom/path"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_connected_before_connect(self):
|
||||
"""is_connected should return False before connect."""
|
||||
bridge = EmbeddedBridge()
|
||||
|
||||
assert await bridge.is_connected() is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_python_when_not_connected(self):
|
||||
"""execute_python should return error when not connected."""
|
||||
bridge = EmbeddedBridge()
|
||||
|
||||
result = await bridge.execute_python("x = 1")
|
||||
|
||||
assert result.success is False
|
||||
assert result.error_type == "ConnectionError"
|
||||
assert "not connected" in result.stderr
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_when_not_connected(self):
|
||||
"""disconnect should handle not being connected."""
|
||||
bridge = EmbeddedBridge()
|
||||
|
||||
# Should not raise
|
||||
await bridge.disconnect()
|
||||
|
||||
assert bridge._connected is False
|
||||
|
||||
|
||||
class TestEmbeddedBridgeCodeExecution:
|
||||
"""Tests for code execution in embedded bridge."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_freecad(self):
|
||||
"""Create a mock FreeCAD module."""
|
||||
mock_fc = mock.MagicMock()
|
||||
mock_fc.Version.return_value = ["0", "21", "2", "2024-01-01"]
|
||||
mock_fc.listDocuments.return_value = {}
|
||||
mock_fc.ActiveDocument = None
|
||||
return mock_fc
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_simple_code(self, mock_freecad):
|
||||
"""execute_python should execute simple Python code."""
|
||||
bridge = EmbeddedBridge()
|
||||
bridge._fc_module = mock_freecad
|
||||
bridge._connected = True
|
||||
|
||||
result = await bridge.execute_python("_result_ = 1 + 1")
|
||||
|
||||
assert result.success is True
|
||||
assert result.result == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_code_with_print(self, mock_freecad):
|
||||
"""execute_python should capture stdout."""
|
||||
bridge = EmbeddedBridge()
|
||||
bridge._fc_module = mock_freecad
|
||||
bridge._connected = True
|
||||
|
||||
result = await bridge.execute_python("print('hello')")
|
||||
|
||||
assert result.success is True
|
||||
assert "hello" in result.stdout
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_code_with_error(self, mock_freecad):
|
||||
"""execute_python should capture exceptions."""
|
||||
bridge = EmbeddedBridge()
|
||||
bridge._fc_module = mock_freecad
|
||||
bridge._connected = True
|
||||
|
||||
result = await bridge.execute_python("raise ValueError('test error')")
|
||||
|
||||
assert result.success is False
|
||||
assert result.error_type == "ValueError"
|
||||
assert result.error_traceback is not None
|
||||
assert "test error" in result.error_traceback
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_code_with_syntax_error(self, mock_freecad):
|
||||
"""execute_python should handle syntax errors."""
|
||||
bridge = EmbeddedBridge()
|
||||
bridge._fc_module = mock_freecad
|
||||
bridge._connected = True
|
||||
|
||||
result = await bridge.execute_python("def bad syntax")
|
||||
|
||||
assert result.success is False
|
||||
assert result.error_type == "SyntaxError"
|
||||
|
||||
|
||||
class TestEmbeddedBridgeDocuments:
|
||||
"""Tests for document handling in embedded bridge."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_freecad_with_doc(self):
|
||||
"""Create mock FreeCAD with a document."""
|
||||
mock_doc = mock.MagicMock()
|
||||
mock_doc.Name = "TestDoc"
|
||||
mock_doc.Label = "Test Document"
|
||||
mock_doc.FileName = "/tmp/test.FCStd"
|
||||
mock_doc.Modified = False
|
||||
mock_doc.Objects = []
|
||||
|
||||
mock_fc = mock.MagicMock()
|
||||
mock_fc.listDocuments.return_value = {"TestDoc": mock_doc}
|
||||
mock_fc.ActiveDocument = mock_doc
|
||||
mock_fc.getDocument.return_value = mock_doc
|
||||
|
||||
return mock_fc
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_documents_empty(self):
|
||||
"""get_documents should return empty list when no docs."""
|
||||
mock_fc = mock.MagicMock()
|
||||
mock_fc.listDocuments.return_value = {}
|
||||
|
||||
bridge = EmbeddedBridge()
|
||||
bridge._fc_module = mock_fc
|
||||
bridge._connected = True
|
||||
|
||||
# Mock the execute_python to return empty list
|
||||
result = await bridge.get_documents()
|
||||
|
||||
# Since we're not mocking exec properly, we expect empty
|
||||
assert isinstance(result, list)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Tests for socket bridge implementation."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from freecad_mcp.bridge.socket import SocketBridge
|
||||
|
||||
|
||||
class TestSocketBridge:
|
||||
"""Tests for SocketBridge class."""
|
||||
|
||||
def test_initialization_defaults(self):
|
||||
"""Bridge should initialize with correct defaults."""
|
||||
bridge = SocketBridge()
|
||||
|
||||
assert bridge._host == "localhost"
|
||||
assert bridge._port == 9876
|
||||
assert bridge._reader is None
|
||||
assert bridge._writer is None
|
||||
|
||||
def test_initialization_custom(self):
|
||||
"""Bridge should accept custom host and port."""
|
||||
bridge = SocketBridge(host="192.168.1.1", port=12345)
|
||||
|
||||
assert bridge._host == "192.168.1.1"
|
||||
assert bridge._port == 12345
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_connected_before_connect(self):
|
||||
"""is_connected should return False before connect."""
|
||||
bridge = SocketBridge()
|
||||
|
||||
assert await bridge.is_connected() is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_failure(self):
|
||||
"""connect should raise ConnectionError on failure."""
|
||||
bridge = SocketBridge(host="invalid-host-12345", port=99999)
|
||||
|
||||
with pytest.raises(ConnectionError):
|
||||
await bridge.connect()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_when_not_connected(self):
|
||||
"""disconnect should handle not being connected."""
|
||||
bridge = SocketBridge()
|
||||
|
||||
# Should not raise
|
||||
await bridge.disconnect()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_python_when_not_connected(self):
|
||||
"""execute_python should return error when not connected."""
|
||||
bridge = SocketBridge()
|
||||
|
||||
result = await bridge.execute_python("x = 1")
|
||||
|
||||
assert result.success is False
|
||||
assert result.error_type == "ConnectionError"
|
||||
|
||||
|
||||
class TestSocketBridgeCommunication:
|
||||
"""Tests for socket communication."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_streams(self):
|
||||
"""Create mock reader and writer streams."""
|
||||
reader = mock.AsyncMock(spec=asyncio.StreamReader)
|
||||
writer = mock.MagicMock(spec=asyncio.StreamWriter)
|
||||
writer.is_closing.return_value = False
|
||||
writer.drain = mock.AsyncMock()
|
||||
writer.close = mock.MagicMock()
|
||||
writer.wait_closed = mock.AsyncMock()
|
||||
return reader, writer
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_request_format(self, mock_streams):
|
||||
"""Requests should be formatted correctly when connected."""
|
||||
reader, writer = mock_streams
|
||||
bridge = SocketBridge()
|
||||
bridge._reader = reader
|
||||
bridge._writer = writer
|
||||
bridge._connected = True
|
||||
|
||||
# Setup response
|
||||
response = {"jsonrpc": "2.0", "id": "test-1", "result": {"success": True}}
|
||||
reader.readline.return_value = json.dumps(response).encode() + b"\n"
|
||||
|
||||
# Our bridge sends and receives in one call
|
||||
# Just verify we can set up the bridge state correctly
|
||||
assert bridge._reader is not None
|
||||
assert bridge._writer is not None
|
||||
assert bridge._connected is True
|
||||
|
||||
|
||||
class TestSocketBridgeDocuments:
|
||||
"""Tests for document handling via socket."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_documents_when_not_connected(self):
|
||||
"""get_documents should return empty list when not connected."""
|
||||
bridge = SocketBridge()
|
||||
|
||||
# This will fail to send request, should return empty
|
||||
docs = await bridge.get_documents()
|
||||
|
||||
assert docs == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_active_document_when_not_connected(self):
|
||||
"""get_active_document should return None when not connected."""
|
||||
bridge = SocketBridge()
|
||||
|
||||
doc = await bridge.get_active_document()
|
||||
|
||||
assert doc is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_gui_available_when_not_connected(self):
|
||||
"""is_gui_available should return False when not connected."""
|
||||
bridge = SocketBridge()
|
||||
|
||||
result = await bridge.is_gui_available()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestSocketBridgeVersionInfo:
|
||||
"""Tests for version info handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_version_when_not_connected(self):
|
||||
"""get_freecad_version should return unknown when not connected."""
|
||||
bridge = SocketBridge()
|
||||
|
||||
version = await bridge.get_freecad_version()
|
||||
|
||||
assert version["version"] == "unknown"
|
||||
assert version["gui_available"] is False
|
||||
Reference in New Issue
Block a user