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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-25 16:25:23 -07:00
parent 95246743c3
commit 395623af66
5 changed files with 93 additions and 58 deletions

View File

@ -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:

View File

@ -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"]

View File

@ -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"]

View File

@ -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-<timestamp>/
1. create_project Create a new KiCad project in /data/files/generated/kicad-<timestamp>/
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-<timestamp>", exist_ok=True)
1. execute_python_script Create output directory: import os; os.makedirs("/data/files/generated/freecad-<timestamp>", 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-<id>/model.step")
7. execute_python_script to export STL: Mesh.export([obj], "/tmp/freecad-gen-<id>/model.stl")
6. execute_python_script to export STEP: Part.export([obj], "/data/files/generated/freecad-<id>/model.step")
7. execute_python_script to export STL: Mesh.export([obj], "/data/files/generated/freecad-<id>/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

View File

@ -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<string | null> {
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<string> {
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<Client> {
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<Client> {
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,