From 395623af662b9fee587e8b44f16a860e0c52f101 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 16:25:23 -0700 Subject: [PATCH] feat(rdesign): deploy KiCad & FreeCAD MCP as Docker sidecars Switch from broken StdioClientTransport (child process) to SSEClientTransport (HTTP to sidecar containers via supergateway). Both sidecars share rspace-files volume so generated CAD files (STEP, STL, Gerber, SVG) are directly servable without copying. Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 20 ++++++++++++ docker/freecad-mcp/Dockerfile | 27 ++++++++++++++++ docker/kicad-mcp/Dockerfile | 31 ++++++++++++++++++ server/cad-orchestrator.ts | 12 +++---- server/index.ts | 61 ++++++----------------------------- 5 files changed, 93 insertions(+), 58 deletions(-) create mode 100644 docker/freecad-mcp/Dockerfile create mode 100644 docker/kicad-mcp/Dockerfile diff --git a/docker-compose.yml b/docker-compose.yml index 1957fba..e3b6c37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -262,6 +262,26 @@ services: retries: 5 start_period: 10s + # ── KiCad MCP sidecar (PCB design via SSE) ── + kicad-mcp: + build: ./docker/kicad-mcp + container_name: kicad-mcp + restart: unless-stopped + volumes: + - rspace-files:/data/files + networks: + - rspace-internal + + # ── FreeCAD MCP sidecar (3D CAD via SSE) ── + freecad-mcp: + build: ./docker/freecad-mcp + container_name: freecad-mcp + restart: unless-stopped + volumes: + - rspace-files:/data/files + networks: + - rspace-internal + # ── Scribus noVNC (rDesign DTP workspace) ── scribus-novnc: build: diff --git a/docker/freecad-mcp/Dockerfile b/docker/freecad-mcp/Dockerfile new file mode 100644 index 0000000..ccd7a19 --- /dev/null +++ b/docker/freecad-mcp/Dockerfile @@ -0,0 +1,27 @@ +FROM node:20-slim + +# Install FreeCAD headless (freecad-cmd) and dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + freecad \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Set headless Qt/FreeCAD env +ENV QT_QPA_PLATFORM=offscreen +ENV DISPLAY="" +ENV FREECAD_USER_CONFIG=/tmp/.FreeCAD + +WORKDIR /app + +# Copy MCP server source +COPY freecad-mcp-server/ . + +# Install Node deps + supergateway (stdio→SSE bridge) +RUN npm install && npm install -g supergateway + +# Ensure generated files dir exists +RUN mkdir -p /data/files/generated + +EXPOSE 8808 + +CMD ["supergateway", "--stdio", "node build/index.js", "--port", "8808"] diff --git a/docker/kicad-mcp/Dockerfile b/docker/kicad-mcp/Dockerfile new file mode 100644 index 0000000..b803f0c --- /dev/null +++ b/docker/kicad-mcp/Dockerfile @@ -0,0 +1,31 @@ +FROM node:20-slim + +# Install KiCad, Python, and build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + kicad \ + python3 \ + python3-pip \ + python3-venv \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Use SWIG backend (headless — no KiCad GUI needed) +ENV KICAD_BACKEND=swig + +WORKDIR /app + +# Copy MCP server source +COPY KiCAD-MCP-Server/ . + +# Install Node deps + supergateway (stdio→SSE bridge) +RUN npm install && npm install -g supergateway + +# Install Python requirements (Pillow, cairosvg, etc.) +RUN pip3 install --break-system-packages -r python/requirements.txt + +# Ensure generated files dir exists +RUN mkdir -p /data/files/generated + +EXPOSE 8809 + +CMD ["supergateway", "--stdio", "node dist/index.js", "--port", "8809"] diff --git a/server/cad-orchestrator.ts b/server/cad-orchestrator.ts index d185940..f83a925 100644 --- a/server/cad-orchestrator.ts +++ b/server/cad-orchestrator.ts @@ -283,7 +283,7 @@ function extractPathFromText(text: string, extensions: string[]): string | null export const KICAD_SYSTEM_PROMPT = `You are a KiCad PCB design assistant. You have access to KiCad MCP tools to create real PCB designs. Follow this workflow: -1. create_project — Create a new KiCad project in /tmp/kicad-gen-/ +1. create_project — Create a new KiCad project in /data/files/generated/kicad-/ 2. search_symbols — Find component symbols in KiCad libraries (e.g. ESP32, BME280, capacitors, resistors) 3. add_schematic_component — Place each component on the schematic 4. add_schematic_net_label — Add net labels for connections @@ -296,7 +296,7 @@ Follow this workflow: 11. export_gerber, export_bom, export_pdf — Generate manufacturing outputs Important: -- Use /tmp/kicad-gen-${Date.now()}/ as the project directory +- Use /data/files/generated/kicad-${Date.now()}/ as the project directory - Search for real symbols before placing components - Add decoupling capacitors and pull-up resistors as needed - Set reasonable board outline dimensions @@ -307,16 +307,16 @@ Important: export const FREECAD_SYSTEM_PROMPT = `You are a FreeCAD parametric CAD assistant. You have access to FreeCAD MCP tools to create real 3D models. Follow this workflow: -1. execute_python_script — Create output directory: import os; os.makedirs("/tmp/freecad-gen-", exist_ok=True) +1. execute_python_script — Create output directory: import os; os.makedirs("/data/files/generated/freecad-", exist_ok=True) 2. Create base geometry using create_box, create_cylinder, or create_sphere 3. Use boolean_operation (union, cut, intersection) to combine shapes 4. list_objects to verify the model state 5. save_document to save the FreeCAD file -6. execute_python_script to export STEP: Part.export([obj], "/tmp/freecad-gen-/model.step") -7. execute_python_script to export STL: Mesh.export([obj], "/tmp/freecad-gen-/model.stl") +6. execute_python_script to export STEP: Part.export([obj], "/data/files/generated/freecad-/model.step") +7. execute_python_script to export STL: Mesh.export([obj], "/data/files/generated/freecad-/model.stl") Important: -- Use /tmp/freecad-gen-${Date.now()}/ as the working directory +- Use /data/files/generated/freecad-${Date.now()}/ as the working directory - For hollow objects, create the outer shell then cut the inner volume - For complex shapes, build up from primitives with boolean operations - Wall thickness should be at least 1mm for 3D printing diff --git a/server/index.ts b/server/index.ts index b3ccb3b..c4ebeb6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1145,20 +1145,6 @@ async function process3DGenJob(job: Gen3DJob) { // ── Image helpers ── -/** Copy a file from a tmp path to the served generated directory → return server-relative URL */ -async function copyToServed(srcPath: string): Promise { - try { - const srcFile = Bun.file(srcPath); - if (!(await srcFile.exists())) return null; - const basename = srcPath.split("/").pop() || `file-${Date.now()}`; - const dir = resolve(process.env.FILES_DIR || "./data/files", "generated"); - await Bun.write(resolve(dir, basename), srcFile); - return `/data/files/generated/${basename}`; - } catch { - return null; - } -} - /** Read a /data/files/generated/... path from disk → base64 */ async function readFileAsBase64(serverPath: string): Promise { const filename = serverPath.split("/").pop(); @@ -1653,22 +1639,18 @@ app.post("/api/blender-gen", async (c) => { } }); -// KiCAD PCB design — MCP stdio bridge +// KiCAD PCB design — MCP SSE bridge (sidecar container) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { runCadAgentLoop, assembleKicadResult, assembleFreecadResult, KICAD_SYSTEM_PROMPT, FREECAD_SYSTEM_PROMPT } from "./cad-orchestrator"; -const KICAD_MCP_PATH = process.env.KICAD_MCP_PATH || "/home/jeffe/KiCAD-MCP-Server/dist/index.js"; +const KICAD_MCP_URL = process.env.KICAD_MCP_URL || "http://kicad-mcp:8809/sse"; let kicadClient: Client | null = null; async function getKicadClient(): Promise { if (kicadClient) return kicadClient; - const transport = new StdioClientTransport({ - command: "node", - args: [KICAD_MCP_PATH], - }); - + const transport = new SSEClientTransport(new URL(KICAD_MCP_URL)); const client = new Client({ name: "rspace-kicad-bridge", version: "1.0.0" }); transport.onclose = () => { kicadClient = null; }; @@ -1705,21 +1687,7 @@ app.post("/api/kicad/generate", async (c) => { const orch = await runCadAgentLoop(client, KICAD_SYSTEM_PROMPT, enrichedPrompt, GEMINI_API_KEY); const result = assembleKicadResult(orch); - // Copy generated files to served directory - const filesToCopy = [ - { path: result.schematicSvg, key: "schematicSvg" }, - { path: result.boardSvg, key: "boardSvg" }, - { path: result.gerberUrl, key: "gerberUrl" }, - { path: result.bomUrl, key: "bomUrl" }, - { path: result.pdfUrl, key: "pdfUrl" }, - ]; - - for (const { path, key } of filesToCopy) { - if (path && path.startsWith("/tmp/")) { - const served = await copyToServed(path); - if (served) (result as any)[key] = served; - } - } + // Files are already on the shared /data/files volume — no copy needed return c.json({ schematic_svg: result.schematicSvg, @@ -1774,18 +1742,14 @@ app.post("/api/kicad/:action", async (c) => { } }); -// FreeCAD parametric CAD — MCP stdio bridge -const FREECAD_MCP_PATH = process.env.FREECAD_MCP_PATH || "/home/jeffe/freecad-mcp-server/build/index.js"; +// FreeCAD parametric CAD — MCP SSE bridge (sidecar container) +const FREECAD_MCP_URL = process.env.FREECAD_MCP_URL || "http://freecad-mcp:8808/sse"; let freecadClient: Client | null = null; async function getFreecadClient(): Promise { if (freecadClient) return freecadClient; - const transport = new StdioClientTransport({ - command: "node", - args: [FREECAD_MCP_PATH], - }); - + const transport = new SSEClientTransport(new URL(FREECAD_MCP_URL)); const client = new Client({ name: "rspace-freecad-bridge", version: "1.0.0" }); transport.onclose = () => { freecadClient = null; }; @@ -1818,14 +1782,7 @@ app.post("/api/freecad/generate", async (c) => { const orch = await runCadAgentLoop(client, FREECAD_SYSTEM_PROMPT, prompt, GEMINI_API_KEY); const result = assembleFreecadResult(orch); - // Copy generated files to served directory - for (const key of ["stepUrl", "stlUrl"] as const) { - const path = result[key]; - if (path && path.startsWith("/tmp/")) { - const served = await copyToServed(path); - if (served) (result as any)[key] = served; - } - } + // Files are already on the shared /data/files volume — no copy needed return c.json({ preview_url: result.previewUrl,