-
Notifications
You must be signed in to change notification settings - Fork 68
MCP Integration
FreeCAD AI supports the Model Context Protocol (MCP) for integrating external tools and exposing its own tools to external clients. Messages are JSON-RPC 2.0, carried over several transports: STDIO (newline-delimited JSON over a subprocess's stdin/stdout) and, since v0.20.0-alpha, HTTP-based transports for connecting to remote servers by URL — see Connecting by URL.
The implementation has zero external dependencies -- it uses only Python stdlib (json, subprocess, threading, sys). The full source lives in freecad_ai/mcp/:
| File | Role |
|---|---|
protocol.py |
JSON-RPC 2.0 encode/decode helpers and message constructors |
transport.py |
Client transports — StdioClientTransport (subprocess), SSEClientTransport (HTTP+SSE), StreamableHTTPClientTransport (Streamable HTTP) — plus server transports StdioServerTransport and SSEServerTransport
|
client.py |
MCPClient -- connects to one external server, discovers tools, calls them; supports deferred schema loading and tool search |
manager.py |
MCPManager singleton -- connects all configured servers, registers tools into the registry with optional deferred parameter loading |
server.py |
MCPServer -- exposes built-in tools to external clients |
The protocol version is 2025-03-26. Client info is reported as FreeCAD AI 0.1.0.
FreeCAD AI can connect to external MCP servers to gain additional tools -- filesystem access, database queries, web APIs, or any capability provided by an MCP-compatible server.
Add MCP servers in the Settings dialog > MCP Servers section, or directly in <FreeCADAI dir>/config.json:
{
"mcp_servers": [
{
"name": "filesystem",
"command": "npx",
"args": ["-y", "@anthropic-ai/mcp-filesystem-server", "/home/user/projects"],
"env": {},
"enabled": true,
"deferred": true
},
{
"name": "my-database",
"command": "python3",
"args": ["/path/to/db-mcp-server.py"],
"env": {"DB_URL": "postgresql://localhost/parts"},
"enabled": true,
"deferred": false
}
]
}Each server entry requires:
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | Unique identifier used for tool namespacing | |
command |
string | Executable to launch (e.g., npx, python3, node) |
|
args |
list | [] |
Command-line arguments |
env |
dict | {} |
Additional environment variables (merged into process env) |
enabled |
bool | true |
Set to false to disable without removing the config |
deferred |
bool | true |
When true, tool schemas are loaded lazily on first use instead of eagerly on connect. Faster startup for servers with many tools. |
transport |
string | "stdio" |
Which transport to use: "stdio" (launch a local subprocess — the default), "sse", or "http" (connect to a remote server by URL). URL transports use url/headers/TLS fields instead of command/args/env — see Connecting by URL. |
timeout |
number | 600 |
Per-server tool-call timeout in seconds (applies to tools/call). The connect/initialize and tools/list handshake uses a fixed 30s. |
Since v0.20.0-alpha. Instead of launching a local subprocess, FreeCAD AI can connect to a remote MCP server over HTTP. Set transport to one of:
-
"http"— the newer Streamable HTTP transport: a single endpoint where responses come back inline (as JSON or atext/event-stream), with anMcp-Session-Idheader returned atinitializeand echoed on later requests. This is the current MCP standard — try it first. -
"sse"— the legacy HTTP+SSE transport:GETan SSE stream for server→client messages plus aPOSTmessage endpoint for client→server. Use it for older servers that don't speak Streamable HTTP.
URL-transport entries replace command/args/env with url and optional headers / TLS fields:
{
"mcp_servers": [
{
"name": "remote-tools",
"transport": "http",
"url": "https://mcp.example.com/mcp",
"headers": {"Authorization": "Bearer YOUR_TOKEN"},
"enabled": true,
"deferred": true
},
{
"name": "legacy-sse",
"transport": "sse",
"url": "https://mcp.example.com/sse",
"headers": {"X-API-Key": "..."},
"timeout": 120
},
{
"name": "local-dev",
"transport": "http",
"url": "http://localhost:3000/mcp"
}
]
}| Field | Type | Default | Description |
|---|---|---|---|
url |
string | Server endpoint. Required for sse/http. Must be https:// — except plaintext http:// is allowed only to loopback hosts (127.0.0.1, localhost, ::1). |
|
headers |
dict | {} |
Extra HTTP headers sent on every request — typically Authorization or API-key headers. |
ca_bundle |
string | Path to a custom CA bundle (PEM) for verifying the server's certificate. Omit to use the system trust store. | |
client_cert |
string | Path to a client certificate (PEM) for mutual TLS. | |
client_key |
string | Path to the client certificate's private key, if kept separate from client_cert. |
Security. URLs are validated before connecting: non-http(s) schemes are rejected, and plaintext http:// is refused for any non-loopback host — use https:// for remote servers so tokens in headers are never sent in the clear. A custom TLS context is built only when you set ca_bundle and/or client_cert; otherwise the system CA store is used.
Like the rest of the MCP stack, these transports are zero-dependency — they are built on urllib from the stdlib. They are the client-side counterpart to the HTTP/SSE server transport (v0.17.0-alpha), which lets other MCP clients connect to FreeCAD AI (see MCP Server below).
MCP tools are registered into the main ToolRegistry with a double-underscore namespace prefix: servername__toolname. For example, if you connect a server named filesystem that exposes a tool called read_file, it becomes available as filesystem__read_file.
This namespacing is handled in MCPManager.register_tools_into():
namespaced = f"{server_name}__{tool_info.name}"The LLM sees these as regular tools alongside the 56 built-in FreeCAD tools. From the agentic loop's perspective, MCP tools are indistinguishable from built-in tools -- they are all ToolDefinition objects with a handler function.
-
Lazy connect: MCP servers are not started when FreeCAD launches. They connect on the first Act-mode message, controlled by the
_mcp_connectedflag inChatDockWidget. This avoids spawning subprocesses that may never be used. -
Initialize handshake: Each client sends an
initializerequest withprotocolVersionandclientInfo, then anotifications/initializednotification, then callstools/listto discover available tools. -
Disconnect: All MCP clients are disconnected when the chat dock widget is closed (
ChatDockWidget.closeEvent()), which callsMCPManager.disconnect_all().
By default ("deferred": true), MCP clients store only tool names and descriptions from the initial tools/list response. Full input schemas (parameter definitions) are loaded lazily on demand:
-
On tool execution:
ToolRegistry.execute()callsToolDefinition.resolve_params()before running the handler, which triggersMCPClient.get_tool_schema()if the schema hasn't been loaded yet. -
On schema generation:
to_openai_schema(),to_anthropic_schema(), andto_mcp_schema()all callresolve_params()for each tool, loading any deferred schemas. -
On tool search:
ToolRegistry.search_tools()andMCPClient.search_tools()resolve schemas only for matching tools.
Schemas are cached after the first load, so subsequent accesses are free. The raw tool list from the initial tools/list response is kept in memory, so resolving a deferred schema does not require a second server round-trip — it simply extracts the inputSchema from the cached response.
Set "deferred": false to eagerly load all schemas on connect (legacy behaviour). This is useful if you want all tool schemas available immediately for schema export or if the server has only a handful of tools.
Both MCPClient and MCPManager expose a search_tools(query) method for keyword-based tool discovery:
from freecad_ai.mcp.manager import get_mcp_manager
manager = get_mcp_manager()
# Search across all connected servers
results = manager.search_tools("file")
# Returns: {"filesystem": [MCPToolInfo("read_file", ...), MCPToolInfo("write_file", ...)]}
# Search on a single client
client = manager._clients["filesystem"]
matches = client.search_tools("read")
# Returns: [MCPToolInfo("read_file", "Read a file from disk")]The ToolRegistry also has a search_tools() method that searches across all registered tools (built-in and MCP):
registry.search_tools("file") # Matches by name or descriptionSearch is case-insensitive substring matching. Deferred schemas are resolved only for matching tools.
- The LLM returns a tool call for
filesystem__read_filewith arguments. - The agentic loop in
chat_widget.pydispatches this toToolRegistry.execute(). - The registry finds the
ToolDefinitionwhose handler was created byMCPManager. - The handler calls
MCPClient.call_tool("read_file", arguments). - The client sends a
tools/callJSON-RPC request to the subprocess. - The subprocess responds with the tool result.
- The result is converted to a
ToolResultand fed back to the LLM for the next turn.
MCP tool schemas (JSON Schema) are converted to ToolParam objects by _json_schema_to_tool_params() in manager.py. This handles type, description, required, enum, default, and items fields. When deferred loading is enabled, this conversion happens lazily via ToolDefinition.lazy_params — a callable that is invoked on first access and then discarded.
FreeCAD AI can also act as an MCP server, exposing all 56 built-in tools to external clients such as Claude Code (claude CLI), Claude Desktop, Cursor, or any MCP-compatible application.
This is worth knowing about even if you already use the addon's own chat panel: driving FreeCAD from a client you already pay for -- a Claude subscription, say -- means no provider API key and no per-token cost on top of it. Thanks to @s-light for working out the recipes below (#55).
Two entry points live in the repository root. They differ in who owns the FreeCAD process, which is the thing to pick on:
mcp_server_http.py |
mcp_server_entry.py |
|
|---|---|---|
| Transport | HTTP + SSE | STDIO |
| FreeCAD process | Already running, with your document open -- you attach to it | Spawned by the client; lives and dies with the session |
| GUI | Yes -- watch the model change live in the viewport | No, headless |
| Good for | Interactive work on a real document | Batch/scripted runs, no display needed |
Both serve the same 56 tools. mcp_server_entry.py additionally:
- Handles the FreeCAD banner output redirection (see FD 3 Workaround)
- Imports FreeCAD and creates a default empty document
- Creates a
ToolRegistrywith built-in tools (no MCP client tools, since it is the server) - Starts the
MCPServerblocking loop
Gotcha, both modes: Claude Code loads MCP tools at session start. A server registered mid-session will not surface its tools until you start a new
claudesession.
Start FreeCAD with the script as an argument -- it launches normally, opens the GUI, and starts the server in a background thread:
# AppImage
/path/to/FreeCAD.AppImage /path/to/freecad-ai/mcp_server_http.py
# Flatpak
flatpak run org.freecad.FreeCAD \
~/.var/app/org.freecad.FreeCAD/data/FreeCAD/v1-1/Mod/freecad-ai/mcp_server_http.pyIt prints its address once the listener is up:
MCP SSE server running on http://127.0.0.1:3000/sse
Set MCP_HOST / MCP_PORT to change the bind address or port if 3000 is taken. You can also start it inside an already-running FreeCAD from the Python console, which is handy when your document is already open:
exec(open("/path/to/freecad-ai/mcp_server_http.py").read())Register it with Claude Code, then start a new session:
claude mcp add --transport sse freecad http://127.0.0.1:3000/sse
claude mcp list # should show: freecad ... ConnectedFor Claude Desktop and other clients that take JSON:
{
"freecad": {
"type": "remote",
"url": "http://127.0.0.1:3000/sse"
}
}Flatpak note. Flatpak's default shared=network permission puts the sandbox on the host network namespace, so 127.0.0.1:3000 is reachable from the host with no sandbox workarounds at all. (Reported by @s-light on a KDE/Wayland Flatpak build of FreeCAD 1.1.1; the maintainer tests on the AppImage, so the Flatpak invocation above is contributor-verified rather than maintainer-verified.)
For Claude Code:
claude mcp add freecad -- bash -c \
'exec 3>&1 1>&2 && /path/to/FreeCAD.AppImage -c /path/to/freecad-ai/mcp_server_entry.py'The bash -c wrapper is required -- it redirects FreeCAD's C++ startup banner away from stdout so it cannot corrupt the JSON-RPC stream. See FD 3 Workaround for why.
Because this mode is headless, list_documents reports every document as unmodified: the dirty flag lives on the Gui document, which does not exist here. See list_documents.
{
"mcpServers": {
"freecad": {
"command": "bash",
"args": [
"-c",
"exec 3>&1 1>&2 && /path/to/FreeCAD.AppImage -c /path/to/freecad-ai/mcp_server_entry.py"
]
}
}
}FreeCAD's C++ runtime prints a banner to fd 1 (stdout) before any Python code runs. This contaminates the JSON-RPC stream. The solution uses a bash wrapper:
exec 3>&1 1>&2 && FreeCAD.AppImage -c mcp_server_entry.pyThis saves the original stdout on fd 3 and redirects fd 1 to stderr. The Python entry point then detects fd 3 and restores it:
_have_saved_fd = True
try:
os.fstat(3)
except OSError:
_have_saved_fd = False
if _have_saved_fd:
os.dup2(3, 1) # Restore real stdout from fd 3
os.close(3)If the script is invoked without the bash wrapper, it falls back to redirecting fd 1 to stderr and restoring from a saved copy after initialization.
FreeCAD's AppImage sets PYTHONHOME and prepends its internal bin directories to PATH. This breaks any subprocess that uses system Python (e.g., npx calling Node.js, or a Python-based MCP server):
ModuleNotFoundError: No module named 'encodings'
The StdioClientTransport.start() method strips these before spawning subprocesses:
for key in ("PYTHONHOME", "PYTHONPATH"):
env.pop(key, None)
path = env.get("PATH", "")
clean_parts = [p for p in path.split(os.pathsep)
if ".mount_FreeCA" not in p]
if clean_parts:
env["PATH"] = os.pathsep.join(clean_parts)The .mount_FreeCA check catches the AppImage's FUSE mount directory (which is named something like /tmp/.mount_FreeCAD_XXXX/usr/bin).
The server handles these JSON-RPC methods:
| Method | Description |
|---|---|
initialize |
Returns protocol version, capabilities, and server info |
notifications/initialized |
Acknowledged silently (no response) |
tools/list |
Returns all registered tools in MCP schema format |
tools/call |
Executes a tool by name with arguments, returns result |
ping |
Returns empty response (health check) |
Unknown methods with an id get a METHOD_NOT_FOUND error. Unknown notifications are silently ignored.
Tool results are returned as MCP content arrays:
{
"content": [
{"type": "text", "text": "Created body 'EnclosureBase'"}
],
"isError": false
}If the tool has structured data in ToolResult.data, it is appended as a second text content block.
All messages follow JSON-RPC 2.0 over newline-delimited JSON (one message per line, no framing headers).
Request (has id, expects response):
{"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "create_body", "arguments": {"name": "Box"}}, "id": 1}Response (matches request id):
{"jsonrpc": "2.0", "id": 1, "result": {"content": [{"type": "text", "text": "Created body 'Box'"}], "isError": false}}Notification (no id, no response):
{"jsonrpc": "2.0", "method": "notifications/initialized"}Error response:
{"jsonrpc": "2.0", "id": 1, "error": {"code": -32601, "message": "Method not found: unknown/method"}}Standard JSON-RPC 2.0 error codes defined in protocol.py:
| Code | Name | Meaning |
|---|---|---|
| -32700 | PARSE_ERROR |
Invalid JSON |
| -32600 | INVALID_REQUEST |
Not a valid JSON-RPC request |
| -32601 | METHOD_NOT_FOUND |
Unknown method |
| -32602 | INVALID_PARAMS |
Invalid method parameters |
| -32603 | INTERNAL_ERROR |
Internal server error |
The StdioClientTransport manages a subprocess with:
-
Threaded reader: a background daemon thread reads stdout line-by-line and matches responses to pending requests by
id. -
Synchronous send:
send_request()blocks the calling thread (with configurable timeout, default 30s) until the matching response arrives. -
Graceful shutdown:
stop()terminates the subprocess, waits 5 seconds, then kills if needed. All pending requests receive anINTERNAL_ERRORresponse. - Monotonic IDs: request IDs are sequential integers starting from 1.
The MCPManager follows the singleton pattern:
from freecad_ai.mcp.manager import get_mcp_manager
manager = get_mcp_manager() # Get or create the global instance
manager.connect_all(server_configs) # Connect to configured servers
manager.register_tools_into(registry) # Register MCP tools
manager.disconnect_all() # Clean up on exitThe registry factory in tools/setup.py integrates MCP automatically:
def create_default_registry(include_mcp: bool = True) -> ToolRegistry:
registry = ToolRegistry()
for tool in ALL_TOOLS:
registry.register(tool)
if include_mcp:
manager = get_mcp_manager()
manager.register_tools_into(registry)
return registryWhen the MCP server entry point creates its registry, it passes include_mcp=False to avoid circular connections.