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:
parent
95246743c3
commit
395623af66
|
|
@ -262,6 +262,26 @@ services:
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
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 (rDesign DTP workspace) ──
|
||||||
scribus-novnc:
|
scribus-novnc:
|
||||||
build:
|
build:
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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.
|
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:
|
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)
|
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
|
3. add_schematic_component — Place each component on the schematic
|
||||||
4. add_schematic_net_label — Add net labels for connections
|
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
|
11. export_gerber, export_bom, export_pdf — Generate manufacturing outputs
|
||||||
|
|
||||||
Important:
|
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
|
- Search for real symbols before placing components
|
||||||
- Add decoupling capacitors and pull-up resistors as needed
|
- Add decoupling capacitors and pull-up resistors as needed
|
||||||
- Set reasonable board outline dimensions
|
- 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.
|
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:
|
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
|
2. Create base geometry using create_box, create_cylinder, or create_sphere
|
||||||
3. Use boolean_operation (union, cut, intersection) to combine shapes
|
3. Use boolean_operation (union, cut, intersection) to combine shapes
|
||||||
4. list_objects to verify the model state
|
4. list_objects to verify the model state
|
||||||
5. save_document to save the FreeCAD file
|
5. save_document to save the FreeCAD file
|
||||||
6. execute_python_script to export STEP: Part.export([obj], "/tmp/freecad-gen-<id>/model.step")
|
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], "/tmp/freecad-gen-<id>/model.stl")
|
7. execute_python_script to export STL: Mesh.export([obj], "/data/files/generated/freecad-<id>/model.stl")
|
||||||
|
|
||||||
Important:
|
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 hollow objects, create the outer shell then cut the inner volume
|
||||||
- For complex shapes, build up from primitives with boolean operations
|
- For complex shapes, build up from primitives with boolean operations
|
||||||
- Wall thickness should be at least 1mm for 3D printing
|
- Wall thickness should be at least 1mm for 3D printing
|
||||||
|
|
|
||||||
|
|
@ -1145,20 +1145,6 @@ async function process3DGenJob(job: Gen3DJob) {
|
||||||
|
|
||||||
// ── Image helpers ──
|
// ── 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 */
|
/** Read a /data/files/generated/... path from disk → base64 */
|
||||||
async function readFileAsBase64(serverPath: string): Promise<string> {
|
async function readFileAsBase64(serverPath: string): Promise<string> {
|
||||||
const filename = serverPath.split("/").pop();
|
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 { 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";
|
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;
|
let kicadClient: Client | null = null;
|
||||||
|
|
||||||
async function getKicadClient(): Promise<Client> {
|
async function getKicadClient(): Promise<Client> {
|
||||||
if (kicadClient) return kicadClient;
|
if (kicadClient) return kicadClient;
|
||||||
|
|
||||||
const transport = new StdioClientTransport({
|
const transport = new SSEClientTransport(new URL(KICAD_MCP_URL));
|
||||||
command: "node",
|
|
||||||
args: [KICAD_MCP_PATH],
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new Client({ name: "rspace-kicad-bridge", version: "1.0.0" });
|
const client = new Client({ name: "rspace-kicad-bridge", version: "1.0.0" });
|
||||||
|
|
||||||
transport.onclose = () => { kicadClient = null; };
|
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 orch = await runCadAgentLoop(client, KICAD_SYSTEM_PROMPT, enrichedPrompt, GEMINI_API_KEY);
|
||||||
const result = assembleKicadResult(orch);
|
const result = assembleKicadResult(orch);
|
||||||
|
|
||||||
// Copy generated files to served directory
|
// Files are already on the shared /data/files volume — no copy needed
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
schematic_svg: result.schematicSvg,
|
schematic_svg: result.schematicSvg,
|
||||||
|
|
@ -1774,18 +1742,14 @@ app.post("/api/kicad/:action", async (c) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// FreeCAD parametric CAD — MCP stdio bridge
|
// FreeCAD parametric CAD — MCP SSE bridge (sidecar container)
|
||||||
const FREECAD_MCP_PATH = process.env.FREECAD_MCP_PATH || "/home/jeffe/freecad-mcp-server/build/index.js";
|
const FREECAD_MCP_URL = process.env.FREECAD_MCP_URL || "http://freecad-mcp:8808/sse";
|
||||||
let freecadClient: Client | null = null;
|
let freecadClient: Client | null = null;
|
||||||
|
|
||||||
async function getFreecadClient(): Promise<Client> {
|
async function getFreecadClient(): Promise<Client> {
|
||||||
if (freecadClient) return freecadClient;
|
if (freecadClient) return freecadClient;
|
||||||
|
|
||||||
const transport = new StdioClientTransport({
|
const transport = new SSEClientTransport(new URL(FREECAD_MCP_URL));
|
||||||
command: "node",
|
|
||||||
args: [FREECAD_MCP_PATH],
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new Client({ name: "rspace-freecad-bridge", version: "1.0.0" });
|
const client = new Client({ name: "rspace-freecad-bridge", version: "1.0.0" });
|
||||||
|
|
||||||
transport.onclose = () => { freecadClient = null; };
|
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 orch = await runCadAgentLoop(client, FREECAD_SYSTEM_PROMPT, prompt, GEMINI_API_KEY);
|
||||||
const result = assembleFreecadResult(orch);
|
const result = assembleFreecadResult(orch);
|
||||||
|
|
||||||
// Copy generated files to served directory
|
// Files are already on the shared /data/files volume — no copy needed
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
preview_url: result.previewUrl,
|
preview_url: result.previewUrl,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue