Skip to content

MCP Integration

alf edited this page Aug 15, 2026 · 5 revisions

MCP Integration

Overview

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.

MCP Client (Using External Tools)

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.

Configuration

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.

Connecting by URL (HTTP/SSE)

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 a text/event-stream), with an Mcp-Session-Id header returned at initialize and echoed on later requests. This is the current MCP standard — try it first.
  • "sse" — the legacy HTTP+SSE transport: GET an SSE stream for server→client messages plus a POST message 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).

Tool Namespacing

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.

Connection Lifecycle

  • Lazy connect: MCP servers are not started when FreeCAD launches. They connect on the first Act-mode message, controlled by the _mcp_connected flag in ChatDockWidget. This avoids spawning subprocesses that may never be used.
  • Initialize handshake: Each client sends an initialize request with protocolVersion and clientInfo, then a notifications/initialized notification, then calls tools/list to discover available tools.
  • Disconnect: All MCP clients are disconnected when the chat dock widget is closed (ChatDockWidget.closeEvent()), which calls MCPManager.disconnect_all().

Deferred Tool Loading

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() calls ToolDefinition.resolve_params() before running the handler, which triggers MCPClient.get_tool_schema() if the schema hasn't been loaded yet.
  • On schema generation: to_openai_schema(), to_anthropic_schema(), and to_mcp_schema() all call resolve_params() for each tool, loading any deferred schemas.
  • On tool search: ToolRegistry.search_tools() and MCPClient.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.

Tool Search

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 description

Search is case-insensitive substring matching. Deferred schemas are resolved only for matching tools.

How Tool Calls Work

  1. The LLM returns a tool call for filesystem__read_file with arguments.
  2. The agentic loop in chat_widget.py dispatches this to ToolRegistry.execute().
  3. The registry finds the ToolDefinition whose handler was created by MCPManager.
  4. The handler calls MCPClient.call_tool("read_file", arguments).
  5. The client sends a tools/call JSON-RPC request to the subprocess.
  6. The subprocess responds with the tool result.
  7. The result is converted to a ToolResult and fed back to the LLM for the next turn.

Schema Conversion

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.

MCP Server (Exposing FreeCAD Tools)

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).

Entry Points

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:

  1. Handles the FreeCAD banner output redirection (see FD 3 Workaround)
  2. Imports FreeCAD and creates a default empty document
  3. Creates a ToolRegistry with built-in tools (no MCP client tools, since it is the server)
  4. Starts the MCPServer blocking 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 claude session.

HTTP/SSE Mode (watch FreeCAD update live)

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.py

It 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 ... Connected

For 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.)

STDIO Mode (headless)

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.

Claude Desktop Configuration

{
  "mcpServers": {
    "freecad": {
      "command": "bash",
      "args": [
        "-c",
        "exec 3>&1 1>&2 && /path/to/FreeCAD.AppImage -c /path/to/freecad-ai/mcp_server_entry.py"
      ]
    }
  }
}

FD 3 Workaround

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.py

This 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.

AppImage Environment Cleanup

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).

Supported MCP Methods

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 Result Format

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.

Protocol Details

JSON-RPC 2.0 Message Types

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"}}

Error Codes

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

Transport Internals

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 an INTERNAL_ERROR response.
  • Monotonic IDs: request IDs are sequential integers starting from 1.

Singleton Pattern

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 exit

The 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 registry

When the MCP server entry point creates its registry, it passes include_mcp=False to avoid circular connections.

Clone this wiki locally