# 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:
--------------------------------------------------------------------------------
```
1 | 3.12
2 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | __pycache__/
2 |
```
--------------------------------------------------------------------------------
/examples/adk/.env:
--------------------------------------------------------------------------------
```
1 | GOOGLE_API_KEY=xxxx
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | [](https://mseep.ai/app/neka-nat-freecad-mcp)
2 |
3 | # FreeCAD MCP
4 |
5 | This repository is a FreeCAD MCP that allows you to control FreeCAD from Claude Desktop.
6 |
7 | ## Demo
8 |
9 | ### Design a flange
10 |
11 | 
12 |
13 | ### Design a toy car
14 |
15 | 
16 |
17 | ### Design a part from 2D drawing
18 |
19 | #### Input 2D drawing
20 |
21 | 
22 |
23 | #### Demo
24 |
25 | 
26 |
27 | This is the conversation history.
28 | https://claude.ai/share/7b48fd60-68ba-46fb-bb21-2fbb17399b48
29 |
30 | ## Install addon
31 |
32 | FreeCAD Addon directory is
33 | * Windows: `%APPDATA%\FreeCAD\Mod\`
34 | * Mac: `~/Library/Application\ Support/FreeCAD/Mod/`
35 | * Linux:
36 | * Ubuntu: `~/.FreeCAD/Mod/` or `~/snap/freecad/common/Mod/` (if you install FreeCAD from snap)
37 | * Debian: `~/.local/share/FreeCAD/Mod`
38 |
39 | Please put `addon/FreeCADMCP` directory to the addon directory.
40 |
41 | ```bash
42 | git clone https://github.com/neka-nat/freecad-mcp.git
43 | cd freecad-mcp
44 | cp -r addon/FreeCADMCP ~/.FreeCAD/Mod/
45 | ```
46 |
47 | When you install addon, you need to restart FreeCAD.
48 | You can select "MCP Addon" from Workbench list and use it.
49 |
50 | 
51 |
52 | And you can start RPC server by "Start RPC Server" command in "FreeCAD MCP" toolbar.
53 |
54 | 
55 |
56 | ## Setting up Claude Desktop
57 |
58 | Pre-installation of the [uvx](https://docs.astral.sh/uv/guides/tools/) is required.
59 |
60 | And you need to edit Claude Desktop config file, `claude_desktop_config.json`.
61 |
62 | For user.
63 |
64 | ```json
65 | {
66 | "mcpServers": {
67 | "freecad": {
68 | "command": "uvx",
69 | "args": [
70 | "freecad-mcp"
71 | ]
72 | }
73 | }
74 | }
75 | ```
76 |
77 | If you want to save token, you can set `only_text_feedback` to `true` and use only text feedback.
78 |
79 | ```json
80 | {
81 | "mcpServers": {
82 | "freecad": {
83 | "command": "uvx",
84 | "args": [
85 | "freecad-mcp",
86 | "--only-text-feedback"
87 | ]
88 | }
89 | }
90 | }
91 | ```
92 |
93 |
94 | For developer.
95 | First, you need clone this repository.
96 |
97 | ```bash
98 | git clone https://github.com/neka-nat/freecad-mcp.git
99 | ```
100 |
101 | ```json
102 | {
103 | "mcpServers": {
104 | "freecad": {
105 | "command": "uv",
106 | "args": [
107 | "--directory",
108 | "/path/to/freecad-mcp/",
109 | "run",
110 | "freecad-mcp"
111 | ]
112 | }
113 | }
114 | }
115 | ```
116 |
117 | ## Tools
118 |
119 | * `create_document`: Create a new document in FreeCAD.
120 | * `create_object`: Create a new object in FreeCAD.
121 | * `edit_object`: Edit an object in FreeCAD.
122 | * `delete_object`: Delete an object in FreeCAD.
123 | * `execute_code`: Execute arbitrary Python code in FreeCAD.
124 | * `insert_part_from_library`: Insert a part from the [parts library](https://github.com/FreeCAD/FreeCAD-library).
125 | * `get_view`: Get a screenshot of the active view.
126 | * `get_objects`: Get all objects in a document.
127 | * `get_object`: Get an object in a document.
128 | * `get_parts_list`: Get the list of parts in the [parts library](https://github.com/FreeCAD/FreeCAD-library).
129 |
130 | ## Contributors
131 |
132 | <a href="https://github.com/neka-nat/freecad-mcp/graphs/contributors">
133 | <img src="https://contrib.rocks/image?repo=neka-nat/freecad-mcp" />
134 | </a>
135 |
136 | Made with [contrib.rocks](https://contrib.rocks).
137 |
```
--------------------------------------------------------------------------------
/src/freecad_mcp/__init__.py:
--------------------------------------------------------------------------------
```python
1 |
```
--------------------------------------------------------------------------------
/addon/FreeCADMCP/Init.py:
--------------------------------------------------------------------------------
```python
1 |
2 |
```
--------------------------------------------------------------------------------
/examples/adk/__init__.py:
--------------------------------------------------------------------------------
```python
1 | from .import agent
```
--------------------------------------------------------------------------------
/addon/FreeCADMCP/rpc_server/__init__.py:
--------------------------------------------------------------------------------
```python
1 | from . import rpc_server
2 |
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
1 | [project]
2 | name = "freecad-mcp"
3 | version = "0.1.13"
4 | description = "Add your description here"
5 | readme = "README.md"
6 | authors = [
7 | { name = "neka-nat", email = "[email protected]" }
8 | ]
9 | license = { text = "MIT" }
10 | requires-python = ">=3.12"
11 | dependencies = [
12 | "mcp[cli]>=1.12.2",
13 | ]
14 |
15 | [project.scripts]
16 | freecad-mcp = "freecad_mcp.server:main"
17 |
18 | [tool.hatch.build.targets.sdist]
19 | exclude = ["assets", "results"]
20 |
21 | [build-system]
22 | requires = ["hatchling"]
23 | build-backend = "hatchling.build"
24 |
```
--------------------------------------------------------------------------------
/addon/FreeCADMCP/InitGui.py:
--------------------------------------------------------------------------------
```python
1 | class FreeCADMCPAddonWorkbench(Workbench):
2 | MenuText = "MCP Addon"
3 | ToolTip = "Addon for MCP Communication"
4 |
5 | def Initialize(self):
6 | from rpc_server import rpc_server
7 |
8 | commands = ["Start_RPC_Server", "Stop_RPC_Server"]
9 | self.appendToolbar("FreeCAD MCP", commands)
10 | self.appendMenu("FreeCAD MCP", commands)
11 |
12 | def Activated(self):
13 | pass
14 |
15 | def Deactivated(self):
16 | pass
17 |
18 | def ContextMenu(self, recipient):
19 | pass
20 |
21 | def GetClassName(self):
22 | return "Gui::PythonWorkbench"
23 |
24 |
25 | Gui.addWorkbench(FreeCADMCPAddonWorkbench())
26 |
```
--------------------------------------------------------------------------------
/examples/adk/agent.py:
--------------------------------------------------------------------------------
```python
1 | from google.adk.agents.llm_agent import LlmAgent
2 | from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset, StdioServerParameters
3 |
4 | # Agent configuration
5 | AGENT_NAME = "cad_design_agent"
6 | MODEL_NAME = "gemini-2.5-flash-lite"
7 | FREECAD_MCP_DIR = "path/to/freecad-mcp" # Replace with actual path
8 |
9 | # Basic instruction
10 | BASIC_PROMPT = "You are a CAD designer."
11 |
12 | # Initialize agent
13 | root_agent = LlmAgent(
14 | model=MODEL_NAME,
15 | name=AGENT_NAME,
16 | instruction=BASIC_PROMPT,
17 | tools=[
18 | MCPToolset(
19 | connection_params=StdioServerParameters(
20 | command="uv",
21 | args=["--directory", FREECAD_MCP_DIR, "run", "freecad-mcp"]
22 | )
23 | )
24 | ]
25 | )
26 |
```
--------------------------------------------------------------------------------
/addon/FreeCADMCP/rpc_server/parts_library.py:
--------------------------------------------------------------------------------
```python
1 | import os
2 | from functools import cache
3 |
4 | import FreeCAD
5 | import FreeCADGui
6 |
7 |
8 | def insert_part_from_library(relative_path):
9 | parts_lib_path = os.path.join(FreeCAD.getUserAppDataDir(), "Mod", "parts_library")
10 | part_path = os.path.join(parts_lib_path, relative_path)
11 |
12 | if not os.path.exists(part_path):
13 | raise FileNotFoundError(f"Not found: {part_path}")
14 |
15 | FreeCADGui.ActiveDocument.mergeProject(part_path)
16 |
17 |
18 | @cache
19 | def get_parts_list() -> list[str]:
20 | parts_lib_path = os.path.join(FreeCAD.getUserAppDataDir(), "Mod", "parts_library")
21 |
22 | if not os.path.exists(parts_lib_path):
23 | raise FileNotFoundError(f"Not found: {parts_lib_path}")
24 |
25 | parts = []
26 |
27 | for root, _, files in os.walk(parts_lib_path):
28 | for file in files:
29 | if file.endswith(".FCStd"):
30 | relative_path = os.path.relpath(os.path.join(root, file), parts_lib_path)
31 | parts.append(relative_path)
32 |
33 | return parts
34 |
```
--------------------------------------------------------------------------------
/examples/langchain/react.py:
--------------------------------------------------------------------------------
```python
1 | import os
2 | import logging
3 | import asyncio
4 | from langchain_groq import ChatGroq
5 | from mcp import ClientSession, StdioServerParameters
6 | from langchain_mcp_adapters.tools import load_mcp_tools
7 | from langgraph.prebuilt import create_react_agent
8 | from langchain_core.messages import SystemMessage, HumanMessage
9 | from mcp.client.stdio import stdio_client
10 |
11 | # Setup logging and environment
12 | logging.basicConfig(level=logging.INFO)
13 |
14 |
15 | # Initialize LLM
16 | llm = ChatGroq(
17 | model="llama-3.1-8b-instant",
18 | temperature=0.7,
19 | name="cad_design_agent"
20 | )
21 |
22 | # MCP server parameters
23 | server_params = StdioServerParameters(
24 | command="uv",
25 | args=["--directory", "path/to/freecad-mcp", "run", "freecad-mcp"]
26 | )
27 |
28 | # Basic CAD assistant prompt
29 | INSTRUCTION = "You are a CAD designer."
30 |
31 | async def main():
32 | if "GROQ_API_KEY" not in os.environ:
33 | logging.error("GROQ_API_KEY is missing.")
34 | return
35 |
36 | logging.info("Starting MCP client...")
37 | async with stdio_client(server_params) as (read, write):
38 | logging.info("Connected to MCP server.")
39 | async with ClientSession(read, write) as session:
40 | await session.initialize()
41 | tools = await load_mcp_tools(session)
42 |
43 | agent = create_react_agent(llm, tools)
44 |
45 | print("\n Ready! Type 'exit' to quit.\n")
46 | while True:
47 | user_input = input("You: ").strip()
48 | if user_input.lower() in ("exit", "quit"):
49 | print("Goodbye!")
50 | break
51 |
52 | messages = [
53 | SystemMessage(content=INSTRUCTION),
54 | HumanMessage(content=user_input)
55 | ]
56 | try:
57 | response = await agent.ainvoke({"messages": messages})
58 | ai_msgs = response.get("messages", [])
59 | if ai_msgs:
60 | print(f"\n{ai_msgs[-1].content}\n")
61 | else:
62 | print("No response from agent.")
63 | except Exception as e:
64 | logging.error(f"Agent error: {e}")
65 | print("Something went wrong.")
66 |
67 | if __name__ == "__main__":
68 | asyncio.run(main())
69 |
```
--------------------------------------------------------------------------------
/addon/FreeCADMCP/rpc_server/serialize.py:
--------------------------------------------------------------------------------
```python
1 | import FreeCAD as App
2 | import json
3 |
4 |
5 | def serialize_value(value):
6 | if isinstance(value, (int, float, str, bool)):
7 | return value
8 | elif isinstance(value, App.Vector):
9 | return {"x": value.x, "y": value.y, "z": value.z}
10 | elif isinstance(value, App.Rotation):
11 | return {
12 | "Axis": {"x": value.Axis.x, "y": value.Axis.y, "z": value.Axis.z},
13 | "Angle": value.Angle,
14 | }
15 | elif isinstance(value, App.Placement):
16 | return {
17 | "Base": serialize_value(value.Base),
18 | "Rotation": serialize_value(value.Rotation),
19 | }
20 | elif isinstance(value, (list, tuple)):
21 | return [serialize_value(v) for v in value]
22 | elif isinstance(value, (App.Color)):
23 | return tuple(value)
24 | else:
25 | return str(value)
26 |
27 |
28 | def serialize_shape(shape):
29 | if shape is None:
30 | return None
31 | return {
32 | "Volume": shape.Volume,
33 | "Area": shape.Area,
34 | "VertexCount": len(shape.Vertexes),
35 | "EdgeCount": len(shape.Edges),
36 | "FaceCount": len(shape.Faces),
37 | }
38 |
39 |
40 | def serialize_view_object(view):
41 | if view is None:
42 | return None
43 | return {
44 | "ShapeColor": serialize_value(view.ShapeColor),
45 | "Transparency": view.Transparency,
46 | "Visibility": view.Visibility,
47 | }
48 |
49 |
50 | def serialize_object(obj):
51 | if isinstance(obj, list):
52 | return [serialize_object(item) for item in obj]
53 | elif isinstance(obj, App.Document):
54 | return {
55 | "Name": obj.Name,
56 | "Label": obj.Label,
57 | "FileName": obj.FileName,
58 | "Objects": [serialize_object(child) for child in obj.Objects],
59 | }
60 | else:
61 | result = {
62 | "Name": obj.Name,
63 | "Label": obj.Label,
64 | "TypeId": obj.TypeId,
65 | "Properties": {},
66 | "Placement": serialize_value(getattr(obj, "Placement", None)),
67 | "Shape": serialize_shape(getattr(obj, "Shape", None)),
68 | "ViewObject": {},
69 | }
70 |
71 | for prop in obj.PropertiesList:
72 | try:
73 | result["Properties"][prop] = serialize_value(getattr(obj, prop))
74 | except Exception as e:
75 | result["Properties"][prop] = f"<error: {str(e)}>"
76 |
77 | if hasattr(obj, "ViewObject") and obj.ViewObject is not None:
78 | view = obj.ViewObject
79 | result["ViewObject"] = serialize_view_object(view)
80 |
81 | return result
82 |
```
--------------------------------------------------------------------------------
/addon/FreeCADMCP/rpc_server/rpc_server.py:
--------------------------------------------------------------------------------
```python
1 | import FreeCAD
2 | import FreeCADGui
3 | import ObjectsFem
4 |
5 | import contextlib
6 | import queue
7 | import base64
8 | import io
9 | import os
10 | import tempfile
11 | import threading
12 | from dataclasses import dataclass, field
13 | from typing import Any
14 | from xmlrpc.server import SimpleXMLRPCServer
15 |
16 | from PySide import QtCore
17 |
18 | from .parts_library import get_parts_list, insert_part_from_library
19 | from .serialize import serialize_object
20 |
21 | rpc_server_thread = None
22 | rpc_server_instance = None
23 |
24 | # GUI task queue
25 | rpc_request_queue = queue.Queue()
26 | rpc_response_queue = queue.Queue()
27 |
28 |
29 | def process_gui_tasks():
30 | while not rpc_request_queue.empty():
31 | task = rpc_request_queue.get()
32 | res = task()
33 | if res is not None:
34 | rpc_response_queue.put(res)
35 | QtCore.QTimer.singleShot(500, process_gui_tasks)
36 |
37 |
38 | @dataclass
39 | class Object:
40 | name: str
41 | type: str | None = None
42 | analysis: str | None = None
43 | properties: dict[str, Any] = field(default_factory=dict)
44 |
45 |
46 | def set_object_property(
47 | doc: FreeCAD.Document, obj: FreeCAD.DocumentObject, properties: dict[str, Any]
48 | ):
49 | for prop, val in properties.items():
50 | try:
51 | if prop in obj.PropertiesList:
52 | if prop == "Placement" and isinstance(val, dict):
53 | if "Base" in val:
54 | pos = val["Base"]
55 | elif "Position" in val:
56 | pos = val["Position"]
57 | else:
58 | pos = {}
59 | rot = val.get("Rotation", {})
60 | placement = FreeCAD.Placement(
61 | FreeCAD.Vector(
62 | pos.get("x", 0),
63 | pos.get("y", 0),
64 | pos.get("z", 0),
65 | ),
66 | FreeCAD.Rotation(
67 | FreeCAD.Vector(
68 | rot.get("Axis", {}).get("x", 0),
69 | rot.get("Axis", {}).get("y", 0),
70 | rot.get("Axis", {}).get("z", 1),
71 | ),
72 | rot.get("Angle", 0),
73 | ),
74 | )
75 | setattr(obj, prop, placement)
76 |
77 | elif isinstance(getattr(obj, prop), FreeCAD.Vector) and isinstance(
78 | val, dict
79 | ):
80 | vector = FreeCAD.Vector(
81 | val.get("x", 0), val.get("y", 0), val.get("z", 0)
82 | )
83 | setattr(obj, prop, vector)
84 |
85 | elif prop in ["Base", "Tool", "Source", "Profile"] and isinstance(
86 | val, str
87 | ):
88 | ref_obj = doc.getObject(val)
89 | if ref_obj:
90 | setattr(obj, prop, ref_obj)
91 | else:
92 | raise ValueError(f"Referenced object '{val}' not found.")
93 |
94 | elif prop == "References" and isinstance(val, list):
95 | refs = []
96 | for ref_name, face in val:
97 | ref_obj = doc.getObject(ref_name)
98 | if ref_obj:
99 | refs.append((ref_obj, face))
100 | else:
101 | raise ValueError(f"Referenced object '{ref_name}' not found.")
102 | setattr(obj, prop, refs)
103 |
104 | else:
105 | setattr(obj, prop, val)
106 | # ShapeColor is a property of the ViewObject
107 | elif prop == "ShapeColor" and isinstance(val, (list, tuple)):
108 | setattr(obj.ViewObject, prop, (float(val[0]), float(val[1]), float(val[2]), float(val[3])))
109 |
110 | elif prop == "ViewObject" and isinstance(val, dict):
111 | for k, v in val.items():
112 | if k == "ShapeColor":
113 | setattr(obj.ViewObject, k, (float(v[0]), float(v[1]), float(v[2]), float(v[3])))
114 | else:
115 | setattr(obj.ViewObject, k, v)
116 |
117 | else:
118 | setattr(obj, prop, val)
119 |
120 | except Exception as e:
121 | FreeCAD.Console.PrintError(f"Property '{prop}' assignment error: {e}\n")
122 |
123 |
124 | class FreeCADRPC:
125 | """RPC server for FreeCAD"""
126 |
127 | def ping(self):
128 | return True
129 |
130 | def create_document(self, name="New_Document"):
131 | rpc_request_queue.put(lambda: self._create_document_gui(name))
132 | res = rpc_response_queue.get()
133 | if res is True:
134 | return {"success": True, "document_name": name}
135 | else:
136 | return {"success": False, "error": res}
137 |
138 | def create_object(self, doc_name, obj_data: dict[str, Any]):
139 | obj = Object(
140 | name=obj_data.get("Name", "New_Object"),
141 | type=obj_data["Type"],
142 | analysis=obj_data.get("Analysis", None),
143 | properties=obj_data.get("Properties", {}),
144 | )
145 | rpc_request_queue.put(lambda: self._create_object_gui(doc_name, obj))
146 | res = rpc_response_queue.get()
147 | if res is True:
148 | return {"success": True, "object_name": obj.name}
149 | else:
150 | return {"success": False, "error": res}
151 |
152 | def edit_object(self, doc_name: str, obj_name: str, properties: dict[str, Any]) -> dict[str, Any]:
153 | obj = Object(
154 | name=obj_name,
155 | properties=properties.get("Properties", {}),
156 | )
157 | rpc_request_queue.put(lambda: self._edit_object_gui(doc_name, obj))
158 | res = rpc_response_queue.get()
159 | if res is True:
160 | return {"success": True, "object_name": obj.name}
161 | else:
162 | return {"success": False, "error": res}
163 |
164 | def delete_object(self, doc_name: str, obj_name: str):
165 | rpc_request_queue.put(lambda: self._delete_object_gui(doc_name, obj_name))
166 | res = rpc_response_queue.get()
167 | if res is True:
168 | return {"success": True, "object_name": obj_name}
169 | else:
170 | return {"success": False, "error": res}
171 |
172 | def execute_code(self, code: str) -> dict[str, Any]:
173 | output_buffer = io.StringIO()
174 | def task():
175 | try:
176 | with contextlib.redirect_stdout(output_buffer):
177 | exec(code, globals())
178 | FreeCAD.Console.PrintMessage("Python code executed successfully.\n")
179 | return True
180 | except Exception as e:
181 | FreeCAD.Console.PrintError(
182 | f"Error executing Python code: {e}\n"
183 | )
184 | return f"Error executing Python code: {e}\n"
185 |
186 | rpc_request_queue.put(task)
187 | res = rpc_response_queue.get()
188 | if res is True:
189 | return {
190 | "success": True,
191 | "message": "Python code execution scheduled. \nOutput: " + output_buffer.getvalue()
192 | }
193 | else:
194 | return {"success": False, "error": res}
195 |
196 | def get_objects(self, doc_name):
197 | doc = FreeCAD.getDocument(doc_name)
198 | if doc:
199 | return [serialize_object(obj) for obj in doc.Objects]
200 | else:
201 | return []
202 |
203 | def get_object(self, doc_name, obj_name):
204 | doc = FreeCAD.getDocument(doc_name)
205 | if doc:
206 | return serialize_object(doc.getObject(obj_name))
207 | else:
208 | return None
209 |
210 | def insert_part_from_library(self, relative_path):
211 | rpc_request_queue.put(lambda: self._insert_part_from_library(relative_path))
212 | res = rpc_response_queue.get()
213 | if res is True:
214 | return {"success": True, "message": "Part inserted from library."}
215 | else:
216 | return {"success": False, "error": res}
217 |
218 | def list_documents(self):
219 | return list(FreeCAD.listDocuments().keys())
220 |
221 | def get_parts_list(self):
222 | return get_parts_list()
223 |
224 | def get_active_screenshot(self, view_name: str = "Isometric") -> str:
225 | """Get a screenshot of the active view.
226 |
227 | Returns a base64-encoded string of the screenshot or None if a screenshot
228 | cannot be captured (e.g., when in TechDraw or Spreadsheet view).
229 | """
230 | # First check if the active view supports screenshots
231 | def check_view_supports_screenshots():
232 | try:
233 | active_view = FreeCADGui.ActiveDocument.ActiveView
234 | if active_view is None:
235 | FreeCAD.Console.PrintWarning("No active view available\n")
236 | return False
237 |
238 | view_type = type(active_view).__name__
239 | has_save_image = hasattr(active_view, 'saveImage')
240 | FreeCAD.Console.PrintMessage(f"View type: {view_type}, Has saveImage: {has_save_image}\n")
241 | return has_save_image
242 | except Exception as e:
243 | FreeCAD.Console.PrintError(f"Error checking view capabilities: {e}\n")
244 | return False
245 |
246 | rpc_request_queue.put(check_view_supports_screenshots)
247 | supports_screenshots = rpc_response_queue.get()
248 |
249 | if not supports_screenshots:
250 | FreeCAD.Console.PrintWarning("Current view does not support screenshots\n")
251 | return None
252 |
253 | # If view supports screenshots, proceed with capture
254 | fd, tmp_path = tempfile.mkstemp(suffix=".png")
255 | os.close(fd)
256 | rpc_request_queue.put(
257 | lambda: self._save_active_screenshot(tmp_path, view_name)
258 | )
259 | res = rpc_response_queue.get()
260 | if res is True:
261 | try:
262 | with open(tmp_path, "rb") as image_file:
263 | image_bytes = image_file.read()
264 | encoded = base64.b64encode(image_bytes).decode("utf-8")
265 | finally:
266 | if os.path.exists(tmp_path):
267 | os.remove(tmp_path)
268 | return encoded
269 | else:
270 | if os.path.exists(tmp_path):
271 | os.remove(tmp_path)
272 | FreeCAD.Console.PrintWarning(f"Failed to capture screenshot: {res}\n")
273 | return None
274 |
275 | def _create_document_gui(self, name):
276 | doc = FreeCAD.newDocument(name)
277 | doc.recompute()
278 | FreeCAD.Console.PrintMessage(f"Document '{name}' created via RPC.\n")
279 | return True
280 |
281 | def _create_object_gui(self, doc_name, obj: Object):
282 | doc = FreeCAD.getDocument(doc_name)
283 | if doc:
284 | try:
285 | if obj.type == "Fem::FemMeshGmsh" and obj.analysis:
286 | from femmesh.gmshtools import GmshTools
287 | res = getattr(doc, obj.analysis).addObject(ObjectsFem.makeMeshGmsh(doc, obj.name))[0]
288 | if "Part" in obj.properties:
289 | target_obj = doc.getObject(obj.properties["Part"])
290 | if target_obj:
291 | res.Part = target_obj
292 | else:
293 | raise ValueError(f"Referenced object '{obj.properties['Part']}' not found.")
294 | del obj.properties["Part"]
295 | else:
296 | raise ValueError("'Part' property not found in properties.")
297 |
298 | for param, value in obj.properties.items():
299 | if hasattr(res, param):
300 | setattr(res, param, value)
301 | doc.recompute()
302 |
303 | gmsh_tools = GmshTools(res)
304 | gmsh_tools.create_mesh()
305 | FreeCAD.Console.PrintMessage(
306 | f"FEM Mesh '{res.Name}' generated successfully in '{doc_name}'.\n"
307 | )
308 | elif obj.type.startswith("Fem::"):
309 | fem_make_methods = {
310 | "MaterialCommon": ObjectsFem.makeMaterialSolid,
311 | "AnalysisPython": ObjectsFem.makeAnalysis,
312 | }
313 | obj_type_short = obj.type.split("::")[1]
314 | method_name = "make" + obj_type_short
315 | make_method = fem_make_methods.get(obj_type_short, getattr(ObjectsFem, method_name, None))
316 |
317 | if callable(make_method):
318 | res = make_method(doc, obj.name)
319 | set_object_property(doc, res, obj.properties)
320 | FreeCAD.Console.PrintMessage(
321 | f"FEM object '{res.Name}' created with '{method_name}'.\n"
322 | )
323 | else:
324 | raise ValueError(f"No creation method '{method_name}' found in ObjectsFem.")
325 | if obj.type != "Fem::AnalysisPython" and obj.analysis:
326 | getattr(doc, obj.analysis).addObject(res)
327 | else:
328 | res = doc.addObject(obj.type, obj.name)
329 | set_object_property(doc, res, obj.properties)
330 | FreeCAD.Console.PrintMessage(
331 | f"{res.TypeId} '{res.Name}' added to '{doc_name}' via RPC.\n"
332 | )
333 |
334 | doc.recompute()
335 | return True
336 | except Exception as e:
337 | return str(e)
338 | else:
339 | FreeCAD.Console.PrintError(f"Document '{doc_name}' not found.\n")
340 | return f"Document '{doc_name}' not found.\n"
341 |
342 | def _edit_object_gui(self, doc_name: str, obj: Object):
343 | doc = FreeCAD.getDocument(doc_name)
344 | if not doc:
345 | FreeCAD.Console.PrintError(f"Document '{doc_name}' not found.\n")
346 | return f"Document '{doc_name}' not found.\n"
347 |
348 | obj_ins = doc.getObject(obj.name)
349 | if not obj_ins:
350 | FreeCAD.Console.PrintError(f"Object '{obj.name}' not found in document '{doc_name}'.\n")
351 | return f"Object '{obj.name}' not found in document '{doc_name}'.\n"
352 |
353 | try:
354 | # For Fem::ConstraintFixed
355 | if hasattr(obj_ins, "References") and "References" in obj.properties:
356 | refs = []
357 | for ref_name, face in obj.properties["References"]:
358 | ref_obj = doc.getObject(ref_name)
359 | if ref_obj:
360 | refs.append((ref_obj, face))
361 | else:
362 | raise ValueError(f"Referenced object '{ref_name}' not found.")
363 | obj_ins.References = refs
364 | FreeCAD.Console.PrintMessage(
365 | f"References updated for '{obj.name}' in '{doc_name}'.\n"
366 | )
367 | # delete References from properties
368 | del obj.properties["References"]
369 | set_object_property(doc, obj_ins, obj.properties)
370 | doc.recompute()
371 | FreeCAD.Console.PrintMessage(f"Object '{obj.name}' updated via RPC.\n")
372 | return True
373 | except Exception as e:
374 | return str(e)
375 |
376 | def _delete_object_gui(self, doc_name: str, obj_name: str):
377 | doc = FreeCAD.getDocument(doc_name)
378 | if not doc:
379 | FreeCAD.Console.PrintError(f"Document '{doc_name}' not found.\n")
380 | return f"Document '{doc_name}' not found.\n"
381 |
382 | try:
383 | doc.removeObject(obj_name)
384 | doc.recompute()
385 | FreeCAD.Console.PrintMessage(f"Object '{obj_name}' deleted via RPC.\n")
386 | return True
387 | except Exception as e:
388 | return str(e)
389 |
390 | def _insert_part_from_library(self, relative_path):
391 | try:
392 | insert_part_from_library(relative_path)
393 | return True
394 | except Exception as e:
395 | return str(e)
396 |
397 | def _save_active_screenshot(self, save_path: str, view_name: str = "Isometric"):
398 | try:
399 | view = FreeCADGui.ActiveDocument.ActiveView
400 | # Check if the view supports screenshots
401 | if not hasattr(view, 'saveImage'):
402 | return "Current view does not support screenshots"
403 |
404 | if view_name == "Isometric":
405 | view.viewIsometric()
406 | elif view_name == "Front":
407 | view.viewFront()
408 | elif view_name == "Top":
409 | view.viewTop()
410 | elif view_name == "Right":
411 | view.viewRight()
412 | elif view_name == "Back":
413 | view.viewBack()
414 | elif view_name == "Left":
415 | view.viewLeft()
416 | elif view_name == "Bottom":
417 | view.viewBottom()
418 | elif view_name == "Dimetric":
419 | view.viewDimetric()
420 | elif view_name == "Trimetric":
421 | view.viewTrimetric()
422 | else:
423 | raise ValueError(f"Invalid view name: {view_name}")
424 | view.fitAll()
425 | view.saveImage(save_path, 1)
426 | return True
427 | except Exception as e:
428 | return str(e)
429 |
430 |
431 | def start_rpc_server(host="localhost", port=9875):
432 | global rpc_server_thread, rpc_server_instance
433 |
434 | if rpc_server_instance:
435 | return "RPC Server already running."
436 |
437 | rpc_server_instance = SimpleXMLRPCServer(
438 | (host, port), allow_none=True, logRequests=False
439 | )
440 | rpc_server_instance.register_instance(FreeCADRPC())
441 |
442 | def server_loop():
443 | FreeCAD.Console.PrintMessage(f"RPC Server started at {host}:{port}\n")
444 | rpc_server_instance.serve_forever()
445 |
446 | rpc_server_thread = threading.Thread(target=server_loop, daemon=True)
447 | rpc_server_thread.start()
448 |
449 | QtCore.QTimer.singleShot(500, process_gui_tasks)
450 |
451 | return f"RPC Server started at {host}:{port}."
452 |
453 |
454 | def stop_rpc_server():
455 | global rpc_server_instance, rpc_server_thread
456 |
457 | if rpc_server_instance:
458 | rpc_server_instance.shutdown()
459 | rpc_server_thread.join()
460 | rpc_server_instance = None
461 | rpc_server_thread = None
462 | FreeCAD.Console.PrintMessage("RPC Server stopped.\n")
463 | return "RPC Server stopped."
464 |
465 | return "RPC Server was not running."
466 |
467 |
468 | class StartRPCServerCommand:
469 | def GetResources(self):
470 | return {"MenuText": "Start RPC Server", "ToolTip": "Start RPC Server"}
471 |
472 | def Activated(self):
473 | msg = start_rpc_server()
474 | FreeCAD.Console.PrintMessage(msg + "\n")
475 |
476 | def IsActive(self):
477 | return True
478 |
479 |
480 | class StopRPCServerCommand:
481 | def GetResources(self):
482 | return {"MenuText": "Stop RPC Server", "ToolTip": "Stop RPC Server"}
483 |
484 | def Activated(self):
485 | msg = stop_rpc_server()
486 | FreeCAD.Console.PrintMessage(msg + "\n")
487 |
488 | def IsActive(self):
489 | return True
490 |
491 |
492 | FreeCADGui.addCommand("Start_RPC_Server", StartRPCServerCommand())
493 | FreeCADGui.addCommand("Stop_RPC_Server", StopRPCServerCommand())
```
--------------------------------------------------------------------------------
/src/freecad_mcp/server.py:
--------------------------------------------------------------------------------
```python
1 | import json
2 | import logging
3 | import xmlrpc.client
4 | from contextlib import asynccontextmanager
5 | from typing import AsyncIterator, Dict, Any, Literal
6 |
7 | from mcp.server.fastmcp import FastMCP, Context
8 | from mcp.types import TextContent, ImageContent
9 |
10 | # Configure logging
11 | logging.basicConfig(
12 | level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
13 | )
14 | logger = logging.getLogger("FreeCADMCPserver")
15 |
16 |
17 | _only_text_feedback = False
18 |
19 |
20 | class FreeCADConnection:
21 | def __init__(self, host: str = "localhost", port: int = 9875):
22 | self.server = xmlrpc.client.ServerProxy(f"http://{host}:{port}", allow_none=True)
23 |
24 | def ping(self) -> bool:
25 | return self.server.ping()
26 |
27 | def create_document(self, name: str) -> dict[str, Any]:
28 | return self.server.create_document(name)
29 |
30 | def create_object(self, doc_name: str, obj_data: dict[str, Any]) -> dict[str, Any]:
31 | return self.server.create_object(doc_name, obj_data)
32 |
33 | def edit_object(self, doc_name: str, obj_name: str, obj_data: dict[str, Any]) -> dict[str, Any]:
34 | return self.server.edit_object(doc_name, obj_name, obj_data)
35 |
36 | def delete_object(self, doc_name: str, obj_name: str) -> dict[str, Any]:
37 | return self.server.delete_object(doc_name, obj_name)
38 |
39 | def insert_part_from_library(self, relative_path: str) -> dict[str, Any]:
40 | return self.server.insert_part_from_library(relative_path)
41 |
42 | def execute_code(self, code: str) -> dict[str, Any]:
43 | return self.server.execute_code(code)
44 |
45 | def get_active_screenshot(self, view_name: str = "Isometric") -> str | None:
46 | try:
47 | # Check if we're in a view that supports screenshots
48 | result = self.server.execute_code("""
49 | import FreeCAD
50 | import FreeCADGui
51 |
52 | if FreeCAD.Gui.ActiveDocument and FreeCAD.Gui.ActiveDocument.ActiveView:
53 | view_type = type(FreeCAD.Gui.ActiveDocument.ActiveView).__name__
54 |
55 | # These view types don't support screenshots
56 | unsupported_views = ['SpreadsheetGui::SheetView', 'DrawingGui::DrawingView', 'TechDrawGui::MDIViewPage']
57 |
58 | if view_type in unsupported_views or not hasattr(FreeCAD.Gui.ActiveDocument.ActiveView, 'saveImage'):
59 | print("Current view does not support screenshots")
60 | False
61 | else:
62 | print(f"Current view supports screenshots: {view_type}")
63 | True
64 | else:
65 | print("No active view")
66 | False
67 | """)
68 |
69 | # If the view doesn't support screenshots, return None
70 | if not result.get("success", False) or "Current view does not support screenshots" in result.get("message", ""):
71 | logger.info("Screenshot unavailable in current view (likely Spreadsheet or TechDraw view)")
72 | return None
73 |
74 | # Otherwise, try to get the screenshot
75 | return self.server.get_active_screenshot(view_name)
76 | except Exception as e:
77 | # Log the error but return None instead of raising an exception
78 | logger.error(f"Error getting screenshot: {e}")
79 | return None
80 |
81 | def get_objects(self, doc_name: str) -> list[dict[str, Any]]:
82 | return self.server.get_objects(doc_name)
83 |
84 | def get_object(self, doc_name: str, obj_name: str) -> dict[str, Any]:
85 | return self.server.get_object(doc_name, obj_name)
86 |
87 | def get_parts_list(self) -> list[str]:
88 | return self.server.get_parts_list()
89 |
90 |
91 | @asynccontextmanager
92 | async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
93 | try:
94 | logger.info("FreeCADMCP server starting up")
95 | try:
96 | _ = get_freecad_connection()
97 | logger.info("Successfully connected to FreeCAD on startup")
98 | except Exception as e:
99 | logger.warning(f"Could not connect to FreeCAD on startup: {str(e)}")
100 | logger.warning(
101 | "Make sure the FreeCAD addon is running before using FreeCAD resources or tools"
102 | )
103 | yield {}
104 | finally:
105 | # Clean up the global connection on shutdown
106 | global _freecad_connection
107 | if _freecad_connection:
108 | logger.info("Disconnecting from FreeCAD on shutdown")
109 | _freecad_connection.disconnect()
110 | _freecad_connection = None
111 | logger.info("FreeCADMCP server shut down")
112 |
113 |
114 | mcp = FastMCP(
115 | "FreeCADMCP",
116 | instructions="FreeCAD integration through the Model Context Protocol",
117 | lifespan=server_lifespan,
118 | )
119 |
120 |
121 | _freecad_connection: FreeCADConnection | None = None
122 |
123 |
124 | def get_freecad_connection():
125 | """Get or create a persistent FreeCAD connection"""
126 | global _freecad_connection
127 | if _freecad_connection is None:
128 | _freecad_connection = FreeCADConnection(host="localhost", port=9875)
129 | if not _freecad_connection.ping():
130 | logger.error("Failed to ping FreeCAD")
131 | _freecad_connection = None
132 | raise Exception(
133 | "Failed to connect to FreeCAD. Make sure the FreeCAD addon is running."
134 | )
135 | return _freecad_connection
136 |
137 |
138 | # Helper function to safely add screenshot to response
139 | def add_screenshot_if_available(response, screenshot):
140 | """Safely add screenshot to response only if it's available"""
141 | if screenshot is not None and not _only_text_feedback:
142 | response.append(ImageContent(type="image", data=screenshot, mimeType="image/png"))
143 | elif not _only_text_feedback:
144 | # Add an informative message that will be seen by the AI model and user
145 | response.append(TextContent(
146 | type="text",
147 | text="Note: Visual preview is unavailable in the current view type (such as TechDraw or Spreadsheet). "
148 | "Switch to a 3D view to see visual feedback."
149 | ))
150 | return response
151 |
152 |
153 | @mcp.tool()
154 | def create_document(ctx: Context, name: str) -> list[TextContent]:
155 | """Create a new document in FreeCAD.
156 |
157 | Args:
158 | name: The name of the document to create.
159 |
160 | Returns:
161 | A message indicating the success or failure of the document creation.
162 |
163 | Examples:
164 | If you want to create a document named "MyDocument", you can use the following data.
165 | ```json
166 | {
167 | "name": "MyDocument"
168 | }
169 | ```
170 | """
171 | freecad = get_freecad_connection()
172 | try:
173 | res = freecad.create_document(name)
174 | if res["success"]:
175 | return [
176 | TextContent(type="text", text=f"Document '{res['document_name']}' created successfully")
177 | ]
178 | else:
179 | return [
180 | TextContent(type="text", text=f"Failed to create document: {res['error']}")
181 | ]
182 | except Exception as e:
183 | logger.error(f"Failed to create document: {str(e)}")
184 | return [
185 | TextContent(type="text", text=f"Failed to create document: {str(e)}")
186 | ]
187 |
188 |
189 | @mcp.tool()
190 | def create_object(
191 | ctx: Context,
192 | doc_name: str,
193 | obj_type: str,
194 | obj_name: str,
195 | analysis_name: str | None = None,
196 | obj_properties: dict[str, Any] = None,
197 | ) -> list[TextContent | ImageContent]:
198 | """Create a new object in FreeCAD.
199 | Object type is starts with "Part::" or "Draft::" or "PartDesign::" or "Fem::".
200 |
201 | Args:
202 | doc_name: The name of the document to create the object in.
203 | obj_type: The type of the object to create (e.g. 'Part::Box', 'Part::Cylinder', 'Draft::Circle', 'PartDesign::Body', etc.).
204 | obj_name: The name of the object to create.
205 | obj_properties: The properties of the object to create.
206 |
207 | Returns:
208 | A message indicating the success or failure of the object creation and a screenshot of the object.
209 |
210 | Examples:
211 | If you want to create a cylinder with a height of 30 and a radius of 10, you can use the following data.
212 | ```json
213 | {
214 | "doc_name": "MyCylinder",
215 | "obj_name": "Cylinder",
216 | "obj_type": "Part::Cylinder",
217 | "obj_properties": {
218 | "Height": 30,
219 | "Radius": 10,
220 | "Placement": {
221 | "Base": {
222 | "x": 10,
223 | "y": 10,
224 | "z": 0
225 | },
226 | "Rotation": {
227 | "Axis": {
228 | "x": 0,
229 | "y": 0,
230 | "z": 1
231 | },
232 | "Angle": 45
233 | }
234 | },
235 | "ViewObject": {
236 | "ShapeColor": [0.5, 0.5, 0.5, 1.0]
237 | }
238 | }
239 | }
240 | ```
241 |
242 | If you want to create a circle with a radius of 10, you can use the following data.
243 | ```json
244 | {
245 | "doc_name": "MyCircle",
246 | "obj_name": "Circle",
247 | "obj_type": "Draft::Circle",
248 | }
249 | ```
250 |
251 | If you want to create a FEM analysis, you can use the following data.
252 | ```json
253 | {
254 | "doc_name": "MyFEMAnalysis",
255 | "obj_name": "FemAnalysis",
256 | "obj_type": "Fem::AnalysisPython",
257 | }
258 | ```
259 |
260 | If you want to create a FEM constraint, you can use the following data.
261 | ```json
262 | {
263 | "doc_name": "MyFEMConstraint",
264 | "obj_name": "FemConstraint",
265 | "obj_type": "Fem::ConstraintFixed",
266 | "analysis_name": "MyFEMAnalysis",
267 | "obj_properties": {
268 | "References": [
269 | {
270 | "object_name": "MyObject",
271 | "face": "Face1"
272 | }
273 | ]
274 | }
275 | }
276 | ```
277 |
278 | If you want to create a FEM mechanical material, you can use the following data.
279 | ```json
280 | {
281 | "doc_name": "MyFEMAnalysis",
282 | "obj_name": "FemMechanicalMaterial",
283 | "obj_type": "Fem::MaterialCommon",
284 | "analysis_name": "MyFEMAnalysis",
285 | "obj_properties": {
286 | "Material": {
287 | "Name": "MyMaterial",
288 | "Density": "7900 kg/m^3",
289 | "YoungModulus": "210 GPa",
290 | "PoissonRatio": 0.3
291 | }
292 | }
293 | }
294 | ```
295 |
296 | If you want to create a FEM mesh, you can use the following data.
297 | The `Part` property is required.
298 | ```json
299 | {
300 | "doc_name": "MyFEMMesh",
301 | "obj_name": "FemMesh",
302 | "obj_type": "Fem::FemMeshGmsh",
303 | "analysis_name": "MyFEMAnalysis",
304 | "obj_properties": {
305 | "Part": "MyObject",
306 | "ElementSizeMax": 10,
307 | "ElementSizeMin": 0.1,
308 | "MeshAlgorithm": 2
309 | }
310 | }
311 | ```
312 | """
313 | freecad = get_freecad_connection()
314 | try:
315 | obj_data = {"Name": obj_name, "Type": obj_type, "Properties": obj_properties or {}, "Analysis": analysis_name}
316 | res = freecad.create_object(doc_name, obj_data)
317 | screenshot = freecad.get_active_screenshot()
318 |
319 | if res["success"]:
320 | response = [
321 | TextContent(type="text", text=f"Object '{res['object_name']}' created successfully"),
322 | ]
323 | return add_screenshot_if_available(response, screenshot)
324 | else:
325 | response = [
326 | TextContent(type="text", text=f"Failed to create object: {res['error']}"),
327 | ]
328 | return add_screenshot_if_available(response, screenshot)
329 | except Exception as e:
330 | logger.error(f"Failed to create object: {str(e)}")
331 | return [
332 | TextContent(type="text", text=f"Failed to create object: {str(e)}")
333 | ]
334 |
335 |
336 | @mcp.tool()
337 | def edit_object(
338 | ctx: Context, doc_name: str, obj_name: str, obj_properties: dict[str, Any]
339 | ) -> list[TextContent | ImageContent]:
340 | """Edit an object in FreeCAD.
341 | This tool is used when the `create_object` tool cannot handle the object creation.
342 |
343 | Args:
344 | doc_name: The name of the document to edit the object in.
345 | obj_name: The name of the object to edit.
346 | obj_properties: The properties of the object to edit.
347 |
348 | Returns:
349 | A message indicating the success or failure of the object editing and a screenshot of the object.
350 | """
351 | freecad = get_freecad_connection()
352 | try:
353 | res = freecad.edit_object(doc_name, obj_name, {"Properties": obj_properties})
354 | screenshot = freecad.get_active_screenshot()
355 |
356 | if res["success"]:
357 | response = [
358 | TextContent(type="text", text=f"Object '{res['object_name']}' edited successfully"),
359 | ]
360 | return add_screenshot_if_available(response, screenshot)
361 | else:
362 | response = [
363 | TextContent(type="text", text=f"Failed to edit object: {res['error']}"),
364 | ]
365 | return add_screenshot_if_available(response, screenshot)
366 | except Exception as e:
367 | logger.error(f"Failed to edit object: {str(e)}")
368 | return [
369 | TextContent(type="text", text=f"Failed to edit object: {str(e)}")
370 | ]
371 |
372 |
373 | @mcp.tool()
374 | def delete_object(ctx: Context, doc_name: str, obj_name: str) -> list[TextContent | ImageContent]:
375 | """Delete an object in FreeCAD.
376 |
377 | Args:
378 | doc_name: The name of the document to delete the object from.
379 | obj_name: The name of the object to delete.
380 |
381 | Returns:
382 | A message indicating the success or failure of the object deletion and a screenshot of the object.
383 | """
384 | freecad = get_freecad_connection()
385 | try:
386 | res = freecad.delete_object(doc_name, obj_name)
387 | screenshot = freecad.get_active_screenshot()
388 |
389 | if res["success"]:
390 | response = [
391 | TextContent(type="text", text=f"Object '{res['object_name']}' deleted successfully"),
392 | ]
393 | return add_screenshot_if_available(response, screenshot)
394 | else:
395 | response = [
396 | TextContent(type="text", text=f"Failed to delete object: {res['error']}"),
397 | ]
398 | return add_screenshot_if_available(response, screenshot)
399 | except Exception as e:
400 | logger.error(f"Failed to delete object: {str(e)}")
401 | return [
402 | TextContent(type="text", text=f"Failed to delete object: {str(e)}")
403 | ]
404 |
405 |
406 | @mcp.tool()
407 | def execute_code(ctx: Context, code: str) -> list[TextContent | ImageContent]:
408 | """Execute arbitrary Python code in FreeCAD.
409 |
410 | Args:
411 | code: The Python code to execute.
412 |
413 | Returns:
414 | A message indicating the success or failure of the code execution, the output of the code execution, and a screenshot of the object.
415 | """
416 | freecad = get_freecad_connection()
417 | try:
418 | res = freecad.execute_code(code)
419 | screenshot = freecad.get_active_screenshot()
420 |
421 | if res["success"]:
422 | response = [
423 | TextContent(type="text", text=f"Code executed successfully: {res['message']}"),
424 | ]
425 | return add_screenshot_if_available(response, screenshot)
426 | else:
427 | response = [
428 | TextContent(type="text", text=f"Failed to execute code: {res['error']}"),
429 | ]
430 | return add_screenshot_if_available(response, screenshot)
431 | except Exception as e:
432 | logger.error(f"Failed to execute code: {str(e)}")
433 | return [
434 | TextContent(type="text", text=f"Failed to execute code: {str(e)}")
435 | ]
436 |
437 |
438 | @mcp.tool()
439 | def get_view(ctx: Context, view_name: Literal["Isometric", "Front", "Top", "Right", "Back", "Left", "Bottom", "Dimetric", "Trimetric"]) -> list[ImageContent | TextContent]:
440 | """Get a screenshot of the active view.
441 |
442 | Args:
443 | view_name: The name of the view to get the screenshot of.
444 | The following views are available:
445 | - "Isometric"
446 | - "Front"
447 | - "Top"
448 | - "Right"
449 | - "Back"
450 | - "Left"
451 | - "Bottom"
452 | - "Dimetric"
453 | - "Trimetric"
454 |
455 | Returns:
456 | A screenshot of the active view.
457 | """
458 | freecad = get_freecad_connection()
459 | screenshot = freecad.get_active_screenshot(view_name)
460 |
461 | if screenshot is not None:
462 | return [ImageContent(type="image", data=screenshot, mimeType="image/png")]
463 | else:
464 | return [TextContent(type="text", text="Cannot get screenshot in the current view type (such as TechDraw or Spreadsheet)")]
465 |
466 |
467 | @mcp.tool()
468 | def insert_part_from_library(ctx: Context, relative_path: str) -> list[TextContent | ImageContent]:
469 | """Insert a part from the parts library addon.
470 |
471 | Args:
472 | relative_path: The relative path of the part to insert.
473 |
474 | Returns:
475 | A message indicating the success or failure of the part insertion and a screenshot of the object.
476 | """
477 | freecad = get_freecad_connection()
478 | try:
479 | res = freecad.insert_part_from_library(relative_path)
480 | screenshot = freecad.get_active_screenshot()
481 |
482 | if res["success"]:
483 | response = [
484 | TextContent(type="text", text=f"Part inserted from library: {res['message']}"),
485 | ]
486 | return add_screenshot_if_available(response, screenshot)
487 | else:
488 | response = [
489 | TextContent(type="text", text=f"Failed to insert part from library: {res['error']}"),
490 | ]
491 | return add_screenshot_if_available(response, screenshot)
492 | except Exception as e:
493 | logger.error(f"Failed to insert part from library: {str(e)}")
494 | return [
495 | TextContent(type="text", text=f"Failed to insert part from library: {str(e)}")
496 | ]
497 |
498 |
499 | @mcp.tool()
500 | def get_objects(ctx: Context, doc_name: str) -> list[dict[str, Any]]:
501 | """Get all objects in a document.
502 | You can use this tool to get the objects in a document to see what you can check or edit.
503 |
504 | Args:
505 | doc_name: The name of the document to get the objects from.
506 |
507 | Returns:
508 | A list of objects in the document and a screenshot of the document.
509 | """
510 | freecad = get_freecad_connection()
511 | try:
512 | screenshot = freecad.get_active_screenshot()
513 | response = [
514 | TextContent(type="text", text=json.dumps(freecad.get_objects(doc_name))),
515 | ]
516 | return add_screenshot_if_available(response, screenshot)
517 | except Exception as e:
518 | logger.error(f"Failed to get objects: {str(e)}")
519 | return [
520 | TextContent(type="text", text=f"Failed to get objects: {str(e)}")
521 | ]
522 |
523 |
524 | @mcp.tool()
525 | def get_object(ctx: Context, doc_name: str, obj_name: str) -> dict[str, Any]:
526 | """Get an object from a document.
527 | You can use this tool to get the properties of an object to see what you can check or edit.
528 |
529 | Args:
530 | doc_name: The name of the document to get the object from.
531 | obj_name: The name of the object to get.
532 |
533 | Returns:
534 | The object and a screenshot of the object.
535 | """
536 | freecad = get_freecad_connection()
537 | try:
538 | screenshot = freecad.get_active_screenshot()
539 | response = [
540 | TextContent(type="text", text=json.dumps(freecad.get_object(doc_name, obj_name))),
541 | ]
542 | return add_screenshot_if_available(response, screenshot)
543 | except Exception as e:
544 | logger.error(f"Failed to get object: {str(e)}")
545 | return [
546 | TextContent(type="text", text=f"Failed to get object: {str(e)}")
547 | ]
548 |
549 |
550 | @mcp.tool()
551 | def get_parts_list(ctx: Context) -> list[str]:
552 | """Get the list of parts in the parts library addon.
553 | """
554 | freecad = get_freecad_connection()
555 | parts = freecad.get_parts_list()
556 | if parts:
557 | return [
558 | TextContent(type="text", text=json.dumps(parts))
559 | ]
560 | else:
561 | return [
562 | TextContent(type="text", text=f"No parts found in the parts library. You must add parts_library addon.")
563 | ]
564 |
565 |
566 | @mcp.prompt()
567 | def asset_creation_strategy() -> str:
568 | return """
569 | Asset Creation Strategy for FreeCAD MCP
570 |
571 | When creating content in FreeCAD, always follow these steps:
572 |
573 | 0. Before starting any task, always use get_objects() to confirm the current state of the document.
574 |
575 | 1. Utilize the parts library:
576 | - Check available parts using get_parts_list().
577 | - If the required part exists in the library, use insert_part_from_library() to insert it into your document.
578 |
579 | 2. If the appropriate asset is not available in the parts library:
580 | - Create basic shapes (e.g., cubes, cylinders, spheres) using create_object().
581 | - Adjust and define detailed properties of the shapes as necessary using edit_object().
582 |
583 | 3. Always assign clear and descriptive names to objects when adding them to the document.
584 |
585 | 4. Explicitly set the position, scale, and rotation properties of created or inserted objects using edit_object() to ensure proper spatial relationships.
586 |
587 | 5. After editing an object, always verify that the set properties have been correctly applied by using get_object().
588 |
589 | 6. If detailed customization or specialized operations are necessary, use execute_code() to run custom Python scripts.
590 |
591 | Only revert to basic creation methods in the following cases:
592 | - When the required asset is not available in the parts library.
593 | - When a basic shape is explicitly requested.
594 | - When creating complex shapes requires custom scripting.
595 | """
596 |
597 |
598 | def main():
599 | """Run the MCP server"""
600 | global _only_text_feedback
601 | import argparse
602 | parser = argparse.ArgumentParser()
603 | parser.add_argument("--only-text-feedback", action="store_true", help="Only return text feedback")
604 | args = parser.parse_args()
605 | _only_text_feedback = args.only_text_feedback
606 | logger.info(f"Only text feedback: {_only_text_feedback}")
607 | mcp.run()
```