# Directory Structure
```
├── .gitignore
├── .python-version
├── addon
│ └── FreeCADMCP
│ ├── Init.py
│ ├── InitGui.py
│ └── rpc_server
│ ├── __init__.py
│ ├── parts_library.py
│ ├── rpc_server.py
│ └── serialize.py
├── assets
│ ├── b9-1.png
│ ├── freecad_mcp4.gif
│ ├── from_2ddrawing.gif
│ ├── make_toycar4.gif
│ ├── start_rpc_server.png
│ └── workbench_list.png
├── examples
│ ├── adk
│ │ ├── __init__.py
│ │ ├── .env
│ │ └── agent.py
│ └── langchain
│ └── react.py
├── LICENSE
├── pyproject.toml
├── README.md
├── src
│ └── freecad_mcp
│ ├── __init__.py
│ ├── py.typed
│ └── server.py
└── uv.lock
```
# Files
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
```
3.12
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
__pycache__/
```
--------------------------------------------------------------------------------
/examples/adk/.env:
--------------------------------------------------------------------------------
```
GOOGLE_API_KEY=xxxx
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
[](https://mseep.ai/app/neka-nat-freecad-mcp)
# FreeCAD MCP
This repository is a FreeCAD MCP that allows you to control FreeCAD from Claude Desktop.
## Demo
### Design a flange

### Design a toy car

### Design a part from 2D drawing
#### Input 2D drawing

#### Demo

This is the conversation history.
https://claude.ai/share/7b48fd60-68ba-46fb-bb21-2fbb17399b48
## Install addon
FreeCAD Addon directory is
* Windows: `%APPDATA%\FreeCAD\Mod\`
* Mac: `~/Library/Application\ Support/FreeCAD/Mod/`
* Linux:
* Ubuntu: `~/.FreeCAD/Mod/` or `~/snap/freecad/common/Mod/` (if you install FreeCAD from snap)
* Debian: `~/.local/share/FreeCAD/Mod`
Please put `addon/FreeCADMCP` directory to the addon directory.
```bash
git clone https://github.com/neka-nat/freecad-mcp.git
cd freecad-mcp
cp -r addon/FreeCADMCP ~/.FreeCAD/Mod/
```
When you install addon, you need to restart FreeCAD.
You can select "MCP Addon" from Workbench list and use it.

And you can start RPC server by "Start RPC Server" command in "FreeCAD MCP" toolbar.

## Setting up Claude Desktop
Pre-installation of the [uvx](https://docs.astral.sh/uv/guides/tools/) is required.
And you need to edit Claude Desktop config file, `claude_desktop_config.json`.
For user.
```json
{
"mcpServers": {
"freecad": {
"command": "uvx",
"args": [
"freecad-mcp"
]
}
}
}
```
If you want to save token, you can set `only_text_feedback` to `true` and use only text feedback.
```json
{
"mcpServers": {
"freecad": {
"command": "uvx",
"args": [
"freecad-mcp",
"--only-text-feedback"
]
}
}
}
```
For developer.
First, you need clone this repository.
```bash
git clone https://github.com/neka-nat/freecad-mcp.git
```
```json
{
"mcpServers": {
"freecad": {
"command": "uv",
"args": [
"--directory",
"/path/to/freecad-mcp/",
"run",
"freecad-mcp"
]
}
}
}
```
## Tools
* `create_document`: Create a new document in FreeCAD.
* `create_object`: Create a new object in FreeCAD.
* `edit_object`: Edit an object in FreeCAD.
* `delete_object`: Delete an object in FreeCAD.
* `execute_code`: Execute arbitrary Python code in FreeCAD.
* `insert_part_from_library`: Insert a part from the [parts library](https://github.com/FreeCAD/FreeCAD-library).
* `get_view`: Get a screenshot of the active view.
* `get_objects`: Get all objects in a document.
* `get_object`: Get an object in a document.
* `get_parts_list`: Get the list of parts in the [parts library](https://github.com/FreeCAD/FreeCAD-library).
## Contributors
<a href="https://github.com/neka-nat/freecad-mcp/graphs/contributors">
<img src="https://contrib.rocks/image?repo=neka-nat/freecad-mcp" />
</a>
Made with [contrib.rocks](https://contrib.rocks).
```
--------------------------------------------------------------------------------
/src/freecad_mcp/__init__.py:
--------------------------------------------------------------------------------
```python
```
--------------------------------------------------------------------------------
/addon/FreeCADMCP/Init.py:
--------------------------------------------------------------------------------
```python
```
--------------------------------------------------------------------------------
/examples/adk/__init__.py:
--------------------------------------------------------------------------------
```python
from .import agent
```
--------------------------------------------------------------------------------
/addon/FreeCADMCP/rpc_server/__init__.py:
--------------------------------------------------------------------------------
```python
from . import rpc_server
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
[project]
name = "freecad-mcp"
version = "0.1.13"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "neka-nat", email = "[email protected]" }
]
license = { text = "MIT" }
requires-python = ">=3.12"
dependencies = [
"mcp[cli]>=1.12.2",
]
[project.scripts]
freecad-mcp = "freecad_mcp.server:main"
[tool.hatch.build.targets.sdist]
exclude = ["assets", "results"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
```
--------------------------------------------------------------------------------
/addon/FreeCADMCP/InitGui.py:
--------------------------------------------------------------------------------
```python
class FreeCADMCPAddonWorkbench(Workbench):
MenuText = "MCP Addon"
ToolTip = "Addon for MCP Communication"
def Initialize(self):
from rpc_server import rpc_server
commands = ["Start_RPC_Server", "Stop_RPC_Server"]
self.appendToolbar("FreeCAD MCP", commands)
self.appendMenu("FreeCAD MCP", commands)
def Activated(self):
pass
def Deactivated(self):
pass
def ContextMenu(self, recipient):
pass
def GetClassName(self):
return "Gui::PythonWorkbench"
Gui.addWorkbench(FreeCADMCPAddonWorkbench())
```
--------------------------------------------------------------------------------
/examples/adk/agent.py:
--------------------------------------------------------------------------------
```python
from google.adk.agents.llm_agent import LlmAgent
from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset, StdioServerParameters
# Agent configuration
AGENT_NAME = "cad_design_agent"
MODEL_NAME = "gemini-2.5-flash-lite"
FREECAD_MCP_DIR = "path/to/freecad-mcp" # Replace with actual path
# Basic instruction
BASIC_PROMPT = "You are a CAD designer."
# Initialize agent
root_agent = LlmAgent(
model=MODEL_NAME,
name=AGENT_NAME,
instruction=BASIC_PROMPT,
tools=[
MCPToolset(
connection_params=StdioServerParameters(
command="uv",
args=["--directory", FREECAD_MCP_DIR, "run", "freecad-mcp"]
)
)
]
)
```
--------------------------------------------------------------------------------
/addon/FreeCADMCP/rpc_server/parts_library.py:
--------------------------------------------------------------------------------
```python
import os
from functools import cache
import FreeCAD
import FreeCADGui
def insert_part_from_library(relative_path):
parts_lib_path = os.path.join(FreeCAD.getUserAppDataDir(), "Mod", "parts_library")
part_path = os.path.join(parts_lib_path, relative_path)
if not os.path.exists(part_path):
raise FileNotFoundError(f"Not found: {part_path}")
FreeCADGui.ActiveDocument.mergeProject(part_path)
@cache
def get_parts_list() -> list[str]:
parts_lib_path = os.path.join(FreeCAD.getUserAppDataDir(), "Mod", "parts_library")
if not os.path.exists(parts_lib_path):
raise FileNotFoundError(f"Not found: {parts_lib_path}")
parts = []
for root, _, files in os.walk(parts_lib_path):
for file in files:
if file.endswith(".FCStd"):
relative_path = os.path.relpath(os.path.join(root, file), parts_lib_path)
parts.append(relative_path)
return parts
```
--------------------------------------------------------------------------------
/examples/langchain/react.py:
--------------------------------------------------------------------------------
```python
import os
import logging
import asyncio
from langchain_groq import ChatGroq
from mcp import ClientSession, StdioServerParameters
from langchain_mcp_adapters.tools import load_mcp_tools
from langgraph.prebuilt import create_react_agent
from langchain_core.messages import SystemMessage, HumanMessage
from mcp.client.stdio import stdio_client
# Setup logging and environment
logging.basicConfig(level=logging.INFO)
# Initialize LLM
llm = ChatGroq(
model="llama-3.1-8b-instant",
temperature=0.7,
name="cad_design_agent"
)
# MCP server parameters
server_params = StdioServerParameters(
command="uv",
args=["--directory", "path/to/freecad-mcp", "run", "freecad-mcp"]
)
# Basic CAD assistant prompt
INSTRUCTION = "You are a CAD designer."
async def main():
if "GROQ_API_KEY" not in os.environ:
logging.error("GROQ_API_KEY is missing.")
return
logging.info("Starting MCP client...")
async with stdio_client(server_params) as (read, write):
logging.info("Connected to MCP server.")
async with ClientSession(read, write) as session:
await session.initialize()
tools = await load_mcp_tools(session)
agent = create_react_agent(llm, tools)
print("\n Ready! Type 'exit' to quit.\n")
while True:
user_input = input("You: ").strip()
if user_input.lower() in ("exit", "quit"):
print("Goodbye!")
break
messages = [
SystemMessage(content=INSTRUCTION),
HumanMessage(content=user_input)
]
try:
response = await agent.ainvoke({"messages": messages})
ai_msgs = response.get("messages", [])
if ai_msgs:
print(f"\n{ai_msgs[-1].content}\n")
else:
print("No response from agent.")
except Exception as e:
logging.error(f"Agent error: {e}")
print("Something went wrong.")
if __name__ == "__main__":
asyncio.run(main())
```
--------------------------------------------------------------------------------
/addon/FreeCADMCP/rpc_server/serialize.py:
--------------------------------------------------------------------------------
```python
import FreeCAD as App
import json
def serialize_value(value):
if isinstance(value, (int, float, str, bool)):
return value
elif isinstance(value, App.Vector):
return {"x": value.x, "y": value.y, "z": value.z}
elif isinstance(value, App.Rotation):
return {
"Axis": {"x": value.Axis.x, "y": value.Axis.y, "z": value.Axis.z},
"Angle": value.Angle,
}
elif isinstance(value, App.Placement):
return {
"Base": serialize_value(value.Base),
"Rotation": serialize_value(value.Rotation),
}
elif isinstance(value, (list, tuple)):
return [serialize_value(v) for v in value]
elif isinstance(value, (App.Color)):
return tuple(value)
else:
return str(value)
def serialize_shape(shape):
if shape is None:
return None
return {
"Volume": shape.Volume,
"Area": shape.Area,
"VertexCount": len(shape.Vertexes),
"EdgeCount": len(shape.Edges),
"FaceCount": len(shape.Faces),
}
def serialize_view_object(view):
if view is None:
return None
return {
"ShapeColor": serialize_value(view.ShapeColor),
"Transparency": view.Transparency,
"Visibility": view.Visibility,
}
def serialize_object(obj):
if isinstance(obj, list):
return [serialize_object(item) for item in obj]
elif isinstance(obj, App.Document):
return {
"Name": obj.Name,
"Label": obj.Label,
"FileName": obj.FileName,
"Objects": [serialize_object(child) for child in obj.Objects],
}
else:
result = {
"Name": obj.Name,
"Label": obj.Label,
"TypeId": obj.TypeId,
"Properties": {},
"Placement": serialize_value(getattr(obj, "Placement", None)),
"Shape": serialize_shape(getattr(obj, "Shape", None)),
"ViewObject": {},
}
for prop in obj.PropertiesList:
try:
result["Properties"][prop] = serialize_value(getattr(obj, prop))
except Exception as e:
result["Properties"][prop] = f"<error: {str(e)}>"
if hasattr(obj, "ViewObject") and obj.ViewObject is not None:
view = obj.ViewObject
result["ViewObject"] = serialize_view_object(view)
return result
```
--------------------------------------------------------------------------------
/addon/FreeCADMCP/rpc_server/rpc_server.py:
--------------------------------------------------------------------------------
```python
import FreeCAD
import FreeCADGui
import ObjectsFem
import contextlib
import queue
import base64
import io
import os
import tempfile
import threading
from dataclasses import dataclass, field
from typing import Any
from xmlrpc.server import SimpleXMLRPCServer
from PySide import QtCore
from .parts_library import get_parts_list, insert_part_from_library
from .serialize import serialize_object
rpc_server_thread = None
rpc_server_instance = None
# GUI task queue
rpc_request_queue = queue.Queue()
rpc_response_queue = queue.Queue()
def process_gui_tasks():
while not rpc_request_queue.empty():
task = rpc_request_queue.get()
res = task()
if res is not None:
rpc_response_queue.put(res)
QtCore.QTimer.singleShot(500, process_gui_tasks)
@dataclass
class Object:
name: str
type: str | None = None
analysis: str | None = None
properties: dict[str, Any] = field(default_factory=dict)
def set_object_property(
doc: FreeCAD.Document, obj: FreeCAD.DocumentObject, properties: dict[str, Any]
):
for prop, val in properties.items():
try:
if prop in obj.PropertiesList:
if prop == "Placement" and isinstance(val, dict):
if "Base" in val:
pos = val["Base"]
elif "Position" in val:
pos = val["Position"]
else:
pos = {}
rot = val.get("Rotation", {})
placement = FreeCAD.Placement(
FreeCAD.Vector(
pos.get("x", 0),
pos.get("y", 0),
pos.get("z", 0),
),
FreeCAD.Rotation(
FreeCAD.Vector(
rot.get("Axis", {}).get("x", 0),
rot.get("Axis", {}).get("y", 0),
rot.get("Axis", {}).get("z", 1),
),
rot.get("Angle", 0),
),
)
setattr(obj, prop, placement)
elif isinstance(getattr(obj, prop), FreeCAD.Vector) and isinstance(
val, dict
):
vector = FreeCAD.Vector(
val.get("x", 0), val.get("y", 0), val.get("z", 0)
)
setattr(obj, prop, vector)
elif prop in ["Base", "Tool", "Source", "Profile"] and isinstance(
val, str
):
ref_obj = doc.getObject(val)
if ref_obj:
setattr(obj, prop, ref_obj)
else:
raise ValueError(f"Referenced object '{val}' not found.")
elif prop == "References" and isinstance(val, list):
refs = []
for ref_name, face in val:
ref_obj = doc.getObject(ref_name)
if ref_obj:
refs.append((ref_obj, face))
else:
raise ValueError(f"Referenced object '{ref_name}' not found.")
setattr(obj, prop, refs)
else:
setattr(obj, prop, val)
# ShapeColor is a property of the ViewObject
elif prop == "ShapeColor" and isinstance(val, (list, tuple)):
setattr(obj.ViewObject, prop, (float(val[0]), float(val[1]), float(val[2]), float(val[3])))
elif prop == "ViewObject" and isinstance(val, dict):
for k, v in val.items():
if k == "ShapeColor":
setattr(obj.ViewObject, k, (float(v[0]), float(v[1]), float(v[2]), float(v[3])))
else:
setattr(obj.ViewObject, k, v)
else:
setattr(obj, prop, val)
except Exception as e:
FreeCAD.Console.PrintError(f"Property '{prop}' assignment error: {e}\n")
class FreeCADRPC:
"""RPC server for FreeCAD"""
def ping(self):
return True
def create_document(self, name="New_Document"):
rpc_request_queue.put(lambda: self._create_document_gui(name))
res = rpc_response_queue.get()
if res is True:
return {"success": True, "document_name": name}
else:
return {"success": False, "error": res}
def create_object(self, doc_name, obj_data: dict[str, Any]):
obj = Object(
name=obj_data.get("Name", "New_Object"),
type=obj_data["Type"],
analysis=obj_data.get("Analysis", None),
properties=obj_data.get("Properties", {}),
)
rpc_request_queue.put(lambda: self._create_object_gui(doc_name, obj))
res = rpc_response_queue.get()
if res is True:
return {"success": True, "object_name": obj.name}
else:
return {"success": False, "error": res}
def edit_object(self, doc_name: str, obj_name: str, properties: dict[str, Any]) -> dict[str, Any]:
obj = Object(
name=obj_name,
properties=properties.get("Properties", {}),
)
rpc_request_queue.put(lambda: self._edit_object_gui(doc_name, obj))
res = rpc_response_queue.get()
if res is True:
return {"success": True, "object_name": obj.name}
else:
return {"success": False, "error": res}
def delete_object(self, doc_name: str, obj_name: str):
rpc_request_queue.put(lambda: self._delete_object_gui(doc_name, obj_name))
res = rpc_response_queue.get()
if res is True:
return {"success": True, "object_name": obj_name}
else:
return {"success": False, "error": res}
def execute_code(self, code: str) -> dict[str, Any]:
output_buffer = io.StringIO()
def task():
try:
with contextlib.redirect_stdout(output_buffer):
exec(code, globals())
FreeCAD.Console.PrintMessage("Python code executed successfully.\n")
return True
except Exception as e:
FreeCAD.Console.PrintError(
f"Error executing Python code: {e}\n"
)
return f"Error executing Python code: {e}\n"
rpc_request_queue.put(task)
res = rpc_response_queue.get()
if res is True:
return {
"success": True,
"message": "Python code execution scheduled. \nOutput: " + output_buffer.getvalue()
}
else:
return {"success": False, "error": res}
def get_objects(self, doc_name):
doc = FreeCAD.getDocument(doc_name)
if doc:
return [serialize_object(obj) for obj in doc.Objects]
else:
return []
def get_object(self, doc_name, obj_name):
doc = FreeCAD.getDocument(doc_name)
if doc:
return serialize_object(doc.getObject(obj_name))
else:
return None
def insert_part_from_library(self, relative_path):
rpc_request_queue.put(lambda: self._insert_part_from_library(relative_path))
res = rpc_response_queue.get()
if res is True:
return {"success": True, "message": "Part inserted from library."}
else:
return {"success": False, "error": res}
def list_documents(self):
return list(FreeCAD.listDocuments().keys())
def get_parts_list(self):
return get_parts_list()
def get_active_screenshot(self, view_name: str = "Isometric") -> str:
"""Get a screenshot of the active view.
Returns a base64-encoded string of the screenshot or None if a screenshot
cannot be captured (e.g., when in TechDraw or Spreadsheet view).
"""
# First check if the active view supports screenshots
def check_view_supports_screenshots():
try:
active_view = FreeCADGui.ActiveDocument.ActiveView
if active_view is None:
FreeCAD.Console.PrintWarning("No active view available\n")
return False
view_type = type(active_view).__name__
has_save_image = hasattr(active_view, 'saveImage')
FreeCAD.Console.PrintMessage(f"View type: {view_type}, Has saveImage: {has_save_image}\n")
return has_save_image
except Exception as e:
FreeCAD.Console.PrintError(f"Error checking view capabilities: {e}\n")
return False
rpc_request_queue.put(check_view_supports_screenshots)
supports_screenshots = rpc_response_queue.get()
if not supports_screenshots:
FreeCAD.Console.PrintWarning("Current view does not support screenshots\n")
return None
# If view supports screenshots, proceed with capture
fd, tmp_path = tempfile.mkstemp(suffix=".png")
os.close(fd)
rpc_request_queue.put(
lambda: self._save_active_screenshot(tmp_path, view_name)
)
res = rpc_response_queue.get()
if res is True:
try:
with open(tmp_path, "rb") as image_file:
image_bytes = image_file.read()
encoded = base64.b64encode(image_bytes).decode("utf-8")
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
return encoded
else:
if os.path.exists(tmp_path):
os.remove(tmp_path)
FreeCAD.Console.PrintWarning(f"Failed to capture screenshot: {res}\n")
return None
def _create_document_gui(self, name):
doc = FreeCAD.newDocument(name)
doc.recompute()
FreeCAD.Console.PrintMessage(f"Document '{name}' created via RPC.\n")
return True
def _create_object_gui(self, doc_name, obj: Object):
doc = FreeCAD.getDocument(doc_name)
if doc:
try:
if obj.type == "Fem::FemMeshGmsh" and obj.analysis:
from femmesh.gmshtools import GmshTools
res = getattr(doc, obj.analysis).addObject(ObjectsFem.makeMeshGmsh(doc, obj.name))[0]
if "Part" in obj.properties:
target_obj = doc.getObject(obj.properties["Part"])
if target_obj:
res.Part = target_obj
else:
raise ValueError(f"Referenced object '{obj.properties['Part']}' not found.")
del obj.properties["Part"]
else:
raise ValueError("'Part' property not found in properties.")
for param, value in obj.properties.items():
if hasattr(res, param):
setattr(res, param, value)
doc.recompute()
gmsh_tools = GmshTools(res)
gmsh_tools.create_mesh()
FreeCAD.Console.PrintMessage(
f"FEM Mesh '{res.Name}' generated successfully in '{doc_name}'.\n"
)
elif obj.type.startswith("Fem::"):
fem_make_methods = {
"MaterialCommon": ObjectsFem.makeMaterialSolid,
"AnalysisPython": ObjectsFem.makeAnalysis,
}
obj_type_short = obj.type.split("::")[1]
method_name = "make" + obj_type_short
make_method = fem_make_methods.get(obj_type_short, getattr(ObjectsFem, method_name, None))
if callable(make_method):
res = make_method(doc, obj.name)
set_object_property(doc, res, obj.properties)
FreeCAD.Console.PrintMessage(
f"FEM object '{res.Name}' created with '{method_name}'.\n"
)
else:
raise ValueError(f"No creation method '{method_name}' found in ObjectsFem.")
if obj.type != "Fem::AnalysisPython" and obj.analysis:
getattr(doc, obj.analysis).addObject(res)
else:
res = doc.addObject(obj.type, obj.name)
set_object_property(doc, res, obj.properties)
FreeCAD.Console.PrintMessage(
f"{res.TypeId} '{res.Name}' added to '{doc_name}' via RPC.\n"
)
doc.recompute()
return True
except Exception as e:
return str(e)
else:
FreeCAD.Console.PrintError(f"Document '{doc_name}' not found.\n")
return f"Document '{doc_name}' not found.\n"
def _edit_object_gui(self, doc_name: str, obj: Object):
doc = FreeCAD.getDocument(doc_name)
if not doc:
FreeCAD.Console.PrintError(f"Document '{doc_name}' not found.\n")
return f"Document '{doc_name}' not found.\n"
obj_ins = doc.getObject(obj.name)
if not obj_ins:
FreeCAD.Console.PrintError(f"Object '{obj.name}' not found in document '{doc_name}'.\n")
return f"Object '{obj.name}' not found in document '{doc_name}'.\n"
try:
# For Fem::ConstraintFixed
if hasattr(obj_ins, "References") and "References" in obj.properties:
refs = []
for ref_name, face in obj.properties["References"]:
ref_obj = doc.getObject(ref_name)
if ref_obj:
refs.append((ref_obj, face))
else:
raise ValueError(f"Referenced object '{ref_name}' not found.")
obj_ins.References = refs
FreeCAD.Console.PrintMessage(
f"References updated for '{obj.name}' in '{doc_name}'.\n"
)
# delete References from properties
del obj.properties["References"]
set_object_property(doc, obj_ins, obj.properties)
doc.recompute()
FreeCAD.Console.PrintMessage(f"Object '{obj.name}' updated via RPC.\n")
return True
except Exception as e:
return str(e)
def _delete_object_gui(self, doc_name: str, obj_name: str):
doc = FreeCAD.getDocument(doc_name)
if not doc:
FreeCAD.Console.PrintError(f"Document '{doc_name}' not found.\n")
return f"Document '{doc_name}' not found.\n"
try:
doc.removeObject(obj_name)
doc.recompute()
FreeCAD.Console.PrintMessage(f"Object '{obj_name}' deleted via RPC.\n")
return True
except Exception as e:
return str(e)
def _insert_part_from_library(self, relative_path):
try:
insert_part_from_library(relative_path)
return True
except Exception as e:
return str(e)
def _save_active_screenshot(self, save_path: str, view_name: str = "Isometric"):
try:
view = FreeCADGui.ActiveDocument.ActiveView
# Check if the view supports screenshots
if not hasattr(view, 'saveImage'):
return "Current view does not support screenshots"
if view_name == "Isometric":
view.viewIsometric()
elif view_name == "Front":
view.viewFront()
elif view_name == "Top":
view.viewTop()
elif view_name == "Right":
view.viewRight()
elif view_name == "Back":
view.viewBack()
elif view_name == "Left":
view.viewLeft()
elif view_name == "Bottom":
view.viewBottom()
elif view_name == "Dimetric":
view.viewDimetric()
elif view_name == "Trimetric":
view.viewTrimetric()
else:
raise ValueError(f"Invalid view name: {view_name}")
view.fitAll()
view.saveImage(save_path, 1)
return True
except Exception as e:
return str(e)
def start_rpc_server(host="localhost", port=9875):
global rpc_server_thread, rpc_server_instance
if rpc_server_instance:
return "RPC Server already running."
rpc_server_instance = SimpleXMLRPCServer(
(host, port), allow_none=True, logRequests=False
)
rpc_server_instance.register_instance(FreeCADRPC())
def server_loop():
FreeCAD.Console.PrintMessage(f"RPC Server started at {host}:{port}\n")
rpc_server_instance.serve_forever()
rpc_server_thread = threading.Thread(target=server_loop, daemon=True)
rpc_server_thread.start()
QtCore.QTimer.singleShot(500, process_gui_tasks)
return f"RPC Server started at {host}:{port}."
def stop_rpc_server():
global rpc_server_instance, rpc_server_thread
if rpc_server_instance:
rpc_server_instance.shutdown()
rpc_server_thread.join()
rpc_server_instance = None
rpc_server_thread = None
FreeCAD.Console.PrintMessage("RPC Server stopped.\n")
return "RPC Server stopped."
return "RPC Server was not running."
class StartRPCServerCommand:
def GetResources(self):
return {"MenuText": "Start RPC Server", "ToolTip": "Start RPC Server"}
def Activated(self):
msg = start_rpc_server()
FreeCAD.Console.PrintMessage(msg + "\n")
def IsActive(self):
return True
class StopRPCServerCommand:
def GetResources(self):
return {"MenuText": "Stop RPC Server", "ToolTip": "Stop RPC Server"}
def Activated(self):
msg = stop_rpc_server()
FreeCAD.Console.PrintMessage(msg + "\n")
def IsActive(self):
return True
FreeCADGui.addCommand("Start_RPC_Server", StartRPCServerCommand())
FreeCADGui.addCommand("Stop_RPC_Server", StopRPCServerCommand())
```
--------------------------------------------------------------------------------
/src/freecad_mcp/server.py:
--------------------------------------------------------------------------------
```python
import json
import logging
import xmlrpc.client
from contextlib import asynccontextmanager
from typing import AsyncIterator, Dict, Any, Literal
from mcp.server.fastmcp import FastMCP, Context
from mcp.types import TextContent, ImageContent
# Configure logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger("FreeCADMCPserver")
_only_text_feedback = False
class FreeCADConnection:
def __init__(self, host: str = "localhost", port: int = 9875):
self.server = xmlrpc.client.ServerProxy(f"http://{host}:{port}", allow_none=True)
def ping(self) -> bool:
return self.server.ping()
def create_document(self, name: str) -> dict[str, Any]:
return self.server.create_document(name)
def create_object(self, doc_name: str, obj_data: dict[str, Any]) -> dict[str, Any]:
return self.server.create_object(doc_name, obj_data)
def edit_object(self, doc_name: str, obj_name: str, obj_data: dict[str, Any]) -> dict[str, Any]:
return self.server.edit_object(doc_name, obj_name, obj_data)
def delete_object(self, doc_name: str, obj_name: str) -> dict[str, Any]:
return self.server.delete_object(doc_name, obj_name)
def insert_part_from_library(self, relative_path: str) -> dict[str, Any]:
return self.server.insert_part_from_library(relative_path)
def execute_code(self, code: str) -> dict[str, Any]:
return self.server.execute_code(code)
def get_active_screenshot(self, view_name: str = "Isometric") -> str | None:
try:
# Check if we're in a view that supports screenshots
result = self.server.execute_code("""
import FreeCAD
import FreeCADGui
if FreeCAD.Gui.ActiveDocument and FreeCAD.Gui.ActiveDocument.ActiveView:
view_type = type(FreeCAD.Gui.ActiveDocument.ActiveView).__name__
# These view types don't support screenshots
unsupported_views = ['SpreadsheetGui::SheetView', 'DrawingGui::DrawingView', 'TechDrawGui::MDIViewPage']
if view_type in unsupported_views or not hasattr(FreeCAD.Gui.ActiveDocument.ActiveView, 'saveImage'):
print("Current view does not support screenshots")
False
else:
print(f"Current view supports screenshots: {view_type}")
True
else:
print("No active view")
False
""")
# If the view doesn't support screenshots, return None
if not result.get("success", False) or "Current view does not support screenshots" in result.get("message", ""):
logger.info("Screenshot unavailable in current view (likely Spreadsheet or TechDraw view)")
return None
# Otherwise, try to get the screenshot
return self.server.get_active_screenshot(view_name)
except Exception as e:
# Log the error but return None instead of raising an exception
logger.error(f"Error getting screenshot: {e}")
return None
def get_objects(self, doc_name: str) -> list[dict[str, Any]]:
return self.server.get_objects(doc_name)
def get_object(self, doc_name: str, obj_name: str) -> dict[str, Any]:
return self.server.get_object(doc_name, obj_name)
def get_parts_list(self) -> list[str]:
return self.server.get_parts_list()
@asynccontextmanager
async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
try:
logger.info("FreeCADMCP server starting up")
try:
_ = get_freecad_connection()
logger.info("Successfully connected to FreeCAD on startup")
except Exception as e:
logger.warning(f"Could not connect to FreeCAD on startup: {str(e)}")
logger.warning(
"Make sure the FreeCAD addon is running before using FreeCAD resources or tools"
)
yield {}
finally:
# Clean up the global connection on shutdown
global _freecad_connection
if _freecad_connection:
logger.info("Disconnecting from FreeCAD on shutdown")
_freecad_connection.disconnect()
_freecad_connection = None
logger.info("FreeCADMCP server shut down")
mcp = FastMCP(
"FreeCADMCP",
instructions="FreeCAD integration through the Model Context Protocol",
lifespan=server_lifespan,
)
_freecad_connection: FreeCADConnection | None = None
def get_freecad_connection():
"""Get or create a persistent FreeCAD connection"""
global _freecad_connection
if _freecad_connection is None:
_freecad_connection = FreeCADConnection(host="localhost", port=9875)
if not _freecad_connection.ping():
logger.error("Failed to ping FreeCAD")
_freecad_connection = None
raise Exception(
"Failed to connect to FreeCAD. Make sure the FreeCAD addon is running."
)
return _freecad_connection
# Helper function to safely add screenshot to response
def add_screenshot_if_available(response, screenshot):
"""Safely add screenshot to response only if it's available"""
if screenshot is not None and not _only_text_feedback:
response.append(ImageContent(type="image", data=screenshot, mimeType="image/png"))
elif not _only_text_feedback:
# Add an informative message that will be seen by the AI model and user
response.append(TextContent(
type="text",
text="Note: Visual preview is unavailable in the current view type (such as TechDraw or Spreadsheet). "
"Switch to a 3D view to see visual feedback."
))
return response
@mcp.tool()
def create_document(ctx: Context, name: str) -> list[TextContent]:
"""Create a new document in FreeCAD.
Args:
name: The name of the document to create.
Returns:
A message indicating the success or failure of the document creation.
Examples:
If you want to create a document named "MyDocument", you can use the following data.
```json
{
"name": "MyDocument"
}
```
"""
freecad = get_freecad_connection()
try:
res = freecad.create_document(name)
if res["success"]:
return [
TextContent(type="text", text=f"Document '{res['document_name']}' created successfully")
]
else:
return [
TextContent(type="text", text=f"Failed to create document: {res['error']}")
]
except Exception as e:
logger.error(f"Failed to create document: {str(e)}")
return [
TextContent(type="text", text=f"Failed to create document: {str(e)}")
]
@mcp.tool()
def create_object(
ctx: Context,
doc_name: str,
obj_type: str,
obj_name: str,
analysis_name: str | None = None,
obj_properties: dict[str, Any] = None,
) -> list[TextContent | ImageContent]:
"""Create a new object in FreeCAD.
Object type is starts with "Part::" or "Draft::" or "PartDesign::" or "Fem::".
Args:
doc_name: The name of the document to create the object in.
obj_type: The type of the object to create (e.g. 'Part::Box', 'Part::Cylinder', 'Draft::Circle', 'PartDesign::Body', etc.).
obj_name: The name of the object to create.
obj_properties: The properties of the object to create.
Returns:
A message indicating the success or failure of the object creation and a screenshot of the object.
Examples:
If you want to create a cylinder with a height of 30 and a radius of 10, you can use the following data.
```json
{
"doc_name": "MyCylinder",
"obj_name": "Cylinder",
"obj_type": "Part::Cylinder",
"obj_properties": {
"Height": 30,
"Radius": 10,
"Placement": {
"Base": {
"x": 10,
"y": 10,
"z": 0
},
"Rotation": {
"Axis": {
"x": 0,
"y": 0,
"z": 1
},
"Angle": 45
}
},
"ViewObject": {
"ShapeColor": [0.5, 0.5, 0.5, 1.0]
}
}
}
```
If you want to create a circle with a radius of 10, you can use the following data.
```json
{
"doc_name": "MyCircle",
"obj_name": "Circle",
"obj_type": "Draft::Circle",
}
```
If you want to create a FEM analysis, you can use the following data.
```json
{
"doc_name": "MyFEMAnalysis",
"obj_name": "FemAnalysis",
"obj_type": "Fem::AnalysisPython",
}
```
If you want to create a FEM constraint, you can use the following data.
```json
{
"doc_name": "MyFEMConstraint",
"obj_name": "FemConstraint",
"obj_type": "Fem::ConstraintFixed",
"analysis_name": "MyFEMAnalysis",
"obj_properties": {
"References": [
{
"object_name": "MyObject",
"face": "Face1"
}
]
}
}
```
If you want to create a FEM mechanical material, you can use the following data.
```json
{
"doc_name": "MyFEMAnalysis",
"obj_name": "FemMechanicalMaterial",
"obj_type": "Fem::MaterialCommon",
"analysis_name": "MyFEMAnalysis",
"obj_properties": {
"Material": {
"Name": "MyMaterial",
"Density": "7900 kg/m^3",
"YoungModulus": "210 GPa",
"PoissonRatio": 0.3
}
}
}
```
If you want to create a FEM mesh, you can use the following data.
The `Part` property is required.
```json
{
"doc_name": "MyFEMMesh",
"obj_name": "FemMesh",
"obj_type": "Fem::FemMeshGmsh",
"analysis_name": "MyFEMAnalysis",
"obj_properties": {
"Part": "MyObject",
"ElementSizeMax": 10,
"ElementSizeMin": 0.1,
"MeshAlgorithm": 2
}
}
```
"""
freecad = get_freecad_connection()
try:
obj_data = {"Name": obj_name, "Type": obj_type, "Properties": obj_properties or {}, "Analysis": analysis_name}
res = freecad.create_object(doc_name, obj_data)
screenshot = freecad.get_active_screenshot()
if res["success"]:
response = [
TextContent(type="text", text=f"Object '{res['object_name']}' created successfully"),
]
return add_screenshot_if_available(response, screenshot)
else:
response = [
TextContent(type="text", text=f"Failed to create object: {res['error']}"),
]
return add_screenshot_if_available(response, screenshot)
except Exception as e:
logger.error(f"Failed to create object: {str(e)}")
return [
TextContent(type="text", text=f"Failed to create object: {str(e)}")
]
@mcp.tool()
def edit_object(
ctx: Context, doc_name: str, obj_name: str, obj_properties: dict[str, Any]
) -> list[TextContent | ImageContent]:
"""Edit an object in FreeCAD.
This tool is used when the `create_object` tool cannot handle the object creation.
Args:
doc_name: The name of the document to edit the object in.
obj_name: The name of the object to edit.
obj_properties: The properties of the object to edit.
Returns:
A message indicating the success or failure of the object editing and a screenshot of the object.
"""
freecad = get_freecad_connection()
try:
res = freecad.edit_object(doc_name, obj_name, {"Properties": obj_properties})
screenshot = freecad.get_active_screenshot()
if res["success"]:
response = [
TextContent(type="text", text=f"Object '{res['object_name']}' edited successfully"),
]
return add_screenshot_if_available(response, screenshot)
else:
response = [
TextContent(type="text", text=f"Failed to edit object: {res['error']}"),
]
return add_screenshot_if_available(response, screenshot)
except Exception as e:
logger.error(f"Failed to edit object: {str(e)}")
return [
TextContent(type="text", text=f"Failed to edit object: {str(e)}")
]
@mcp.tool()
def delete_object(ctx: Context, doc_name: str, obj_name: str) -> list[TextContent | ImageContent]:
"""Delete an object in FreeCAD.
Args:
doc_name: The name of the document to delete the object from.
obj_name: The name of the object to delete.
Returns:
A message indicating the success or failure of the object deletion and a screenshot of the object.
"""
freecad = get_freecad_connection()
try:
res = freecad.delete_object(doc_name, obj_name)
screenshot = freecad.get_active_screenshot()
if res["success"]:
response = [
TextContent(type="text", text=f"Object '{res['object_name']}' deleted successfully"),
]
return add_screenshot_if_available(response, screenshot)
else:
response = [
TextContent(type="text", text=f"Failed to delete object: {res['error']}"),
]
return add_screenshot_if_available(response, screenshot)
except Exception as e:
logger.error(f"Failed to delete object: {str(e)}")
return [
TextContent(type="text", text=f"Failed to delete object: {str(e)}")
]
@mcp.tool()
def execute_code(ctx: Context, code: str) -> list[TextContent | ImageContent]:
"""Execute arbitrary Python code in FreeCAD.
Args:
code: The Python code to execute.
Returns:
A message indicating the success or failure of the code execution, the output of the code execution, and a screenshot of the object.
"""
freecad = get_freecad_connection()
try:
res = freecad.execute_code(code)
screenshot = freecad.get_active_screenshot()
if res["success"]:
response = [
TextContent(type="text", text=f"Code executed successfully: {res['message']}"),
]
return add_screenshot_if_available(response, screenshot)
else:
response = [
TextContent(type="text", text=f"Failed to execute code: {res['error']}"),
]
return add_screenshot_if_available(response, screenshot)
except Exception as e:
logger.error(f"Failed to execute code: {str(e)}")
return [
TextContent(type="text", text=f"Failed to execute code: {str(e)}")
]
@mcp.tool()
def get_view(ctx: Context, view_name: Literal["Isometric", "Front", "Top", "Right", "Back", "Left", "Bottom", "Dimetric", "Trimetric"]) -> list[ImageContent | TextContent]:
"""Get a screenshot of the active view.
Args:
view_name: The name of the view to get the screenshot of.
The following views are available:
- "Isometric"
- "Front"
- "Top"
- "Right"
- "Back"
- "Left"
- "Bottom"
- "Dimetric"
- "Trimetric"
Returns:
A screenshot of the active view.
"""
freecad = get_freecad_connection()
screenshot = freecad.get_active_screenshot(view_name)
if screenshot is not None:
return [ImageContent(type="image", data=screenshot, mimeType="image/png")]
else:
return [TextContent(type="text", text="Cannot get screenshot in the current view type (such as TechDraw or Spreadsheet)")]
@mcp.tool()
def insert_part_from_library(ctx: Context, relative_path: str) -> list[TextContent | ImageContent]:
"""Insert a part from the parts library addon.
Args:
relative_path: The relative path of the part to insert.
Returns:
A message indicating the success or failure of the part insertion and a screenshot of the object.
"""
freecad = get_freecad_connection()
try:
res = freecad.insert_part_from_library(relative_path)
screenshot = freecad.get_active_screenshot()
if res["success"]:
response = [
TextContent(type="text", text=f"Part inserted from library: {res['message']}"),
]
return add_screenshot_if_available(response, screenshot)
else:
response = [
TextContent(type="text", text=f"Failed to insert part from library: {res['error']}"),
]
return add_screenshot_if_available(response, screenshot)
except Exception as e:
logger.error(f"Failed to insert part from library: {str(e)}")
return [
TextContent(type="text", text=f"Failed to insert part from library: {str(e)}")
]
@mcp.tool()
def get_objects(ctx: Context, doc_name: str) -> list[dict[str, Any]]:
"""Get all objects in a document.
You can use this tool to get the objects in a document to see what you can check or edit.
Args:
doc_name: The name of the document to get the objects from.
Returns:
A list of objects in the document and a screenshot of the document.
"""
freecad = get_freecad_connection()
try:
screenshot = freecad.get_active_screenshot()
response = [
TextContent(type="text", text=json.dumps(freecad.get_objects(doc_name))),
]
return add_screenshot_if_available(response, screenshot)
except Exception as e:
logger.error(f"Failed to get objects: {str(e)}")
return [
TextContent(type="text", text=f"Failed to get objects: {str(e)}")
]
@mcp.tool()
def get_object(ctx: Context, doc_name: str, obj_name: str) -> dict[str, Any]:
"""Get an object from a document.
You can use this tool to get the properties of an object to see what you can check or edit.
Args:
doc_name: The name of the document to get the object from.
obj_name: The name of the object to get.
Returns:
The object and a screenshot of the object.
"""
freecad = get_freecad_connection()
try:
screenshot = freecad.get_active_screenshot()
response = [
TextContent(type="text", text=json.dumps(freecad.get_object(doc_name, obj_name))),
]
return add_screenshot_if_available(response, screenshot)
except Exception as e:
logger.error(f"Failed to get object: {str(e)}")
return [
TextContent(type="text", text=f"Failed to get object: {str(e)}")
]
@mcp.tool()
def get_parts_list(ctx: Context) -> list[str]:
"""Get the list of parts in the parts library addon.
"""
freecad = get_freecad_connection()
parts = freecad.get_parts_list()
if parts:
return [
TextContent(type="text", text=json.dumps(parts))
]
else:
return [
TextContent(type="text", text=f"No parts found in the parts library. You must add parts_library addon.")
]
@mcp.prompt()
def asset_creation_strategy() -> str:
return """
Asset Creation Strategy for FreeCAD MCP
When creating content in FreeCAD, always follow these steps:
0. Before starting any task, always use get_objects() to confirm the current state of the document.
1. Utilize the parts library:
- Check available parts using get_parts_list().
- If the required part exists in the library, use insert_part_from_library() to insert it into your document.
2. If the appropriate asset is not available in the parts library:
- Create basic shapes (e.g., cubes, cylinders, spheres) using create_object().
- Adjust and define detailed properties of the shapes as necessary using edit_object().
3. Always assign clear and descriptive names to objects when adding them to the document.
4. Explicitly set the position, scale, and rotation properties of created or inserted objects using edit_object() to ensure proper spatial relationships.
5. After editing an object, always verify that the set properties have been correctly applied by using get_object().
6. If detailed customization or specialized operations are necessary, use execute_code() to run custom Python scripts.
Only revert to basic creation methods in the following cases:
- When the required asset is not available in the parts library.
- When a basic shape is explicitly requested.
- When creating complex shapes requires custom scripting.
"""
def main():
"""Run the MCP server"""
global _only_text_feedback
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--only-text-feedback", action="store_true", help="Only return text feedback")
args = parser.parse_args()
_only_text_feedback = args.only_text_feedback
logger.info(f"Only text feedback: {_only_text_feedback}")
mcp.run()
```