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
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue