/** * CAD Orchestrator — LLM-driven MCP tool calling for KiCad and FreeCAD * * Converts MCP tool schemas to Gemini function declarations, runs an agentic * loop where Gemini Flash plans and executes real MCP tool sequences, then * assembles artifacts (SVGs, exports) for the frontend. */ import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; // ── MCP → Gemini schema conversion ── /** Strip keys that Gemini function-calling schema rejects */ function cleanSchema(obj: any): any { if (obj === null || obj === undefined) return obj; if (Array.isArray(obj)) return obj.map(cleanSchema); if (typeof obj !== "object") return obj; const out: any = {}; for (const [k, v] of Object.entries(obj)) { // Gemini rejects these in FunctionDeclaration parameters if (["default", "additionalProperties", "$schema", "examples", "title"].includes(k)) continue; out[k] = cleanSchema(v); } return out; } /** Convert a single MCP Tool to a Gemini FunctionDeclaration */ export function mcpToolToGeminiFn(tool: Tool): any { const params = tool.inputSchema ? cleanSchema(tool.inputSchema) : { type: "object", properties: {} }; // Gemini requires type:"OBJECT" (uppercase) in some SDK versions, but @google/generative-ai handles lowercase return { name: tool.name, description: tool.description || tool.name, parameters: params, }; } // ── Extract text from MCP CallToolResult ── export function extractMcpText(result: any): string { const content = result?.content; if (!Array.isArray(content)) return JSON.stringify(result); return content .filter((c: any) => c.type === "text") .map((c: any) => c.text || "") .join("\n"); } // ── Agentic loop ── export interface ToolCallLogEntry { tool: string; args: Record; result: string; } export interface OrchestrationResult { artifacts: Map; finalMessage: string; toolCallLog: ToolCallLogEntry[]; } export async function runCadAgentLoop( client: Client, systemPrompt: string, userPrompt: string, geminiApiKey: string, maxTurns = 8, ): Promise { // 1. Fetch real tool schemas from MCP server const { tools } = await client.listTools(); const functionDeclarations = tools.map(mcpToolToGeminiFn); // 2. Initialize Gemini Flash with function calling const { GoogleGenerativeAI } = await import("@google/generative-ai"); const genAI = new GoogleGenerativeAI(geminiApiKey); const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash", systemInstruction: systemPrompt, generationConfig: { temperature: 0.2 }, tools: [{ functionDeclarations }], }); // 3. Agentic loop const artifacts = new Map(); const toolCallLog: ToolCallLogEntry[] = []; const deadline = Date.now() + 60_000; let contents: any[] = [ { role: "user", parts: [{ text: userPrompt }] }, ]; for (let turn = 0; turn < maxTurns; turn++) { if (Date.now() > deadline) { console.warn("[cad-orchestrator] 60s deadline reached"); break; } const result = await model.generateContent({ contents }); const candidate = result.response.candidates?.[0]; if (!candidate) break; const parts = candidate.content?.parts || []; const fnCalls = parts.filter((p: any) => p.functionCall); if (fnCalls.length === 0) { // Done — extract final text const finalText = parts .filter((p: any) => p.text) .map((p: any) => p.text) .join("\n"); return { artifacts, finalMessage: finalText || "Design complete.", toolCallLog }; } // Execute each function call on MCP const fnResponseParts: any[] = []; for (const part of fnCalls) { const fc = part.functionCall!; console.log(`[cad-orchestrator] Turn ${turn + 1}: ${fc.name}(${JSON.stringify(fc.args).slice(0, 200)})`); let mcpResultText: string; try { const mcpResult = await client.callTool({ name: fc.name, arguments: (fc.args || {}) as Record, }); mcpResultText = extractMcpText(mcpResult); } catch (err) { mcpResultText = `Error: ${err instanceof Error ? err.message : String(err)}`; } toolCallLog.push({ tool: fc.name, args: fc.args || {}, result: mcpResultText }); artifacts.set(fc.name, mcpResultText); fnResponseParts.push({ functionResponse: { name: fc.name, response: { result: mcpResultText }, }, }); } // Feed results back for next turn contents.push({ role: "model", parts }); contents.push({ role: "user", parts: fnResponseParts }); } return { artifacts, finalMessage: "Design generation completed (max turns reached).", toolCallLog, }; } // ── Result assemblers ── export interface KicadResult { schematicSvg: string | null; boardSvg: string | null; gerberUrl: string | null; bomUrl: string | null; pdfUrl: string | null; drcResults: { violations: string[] } | null; summary: string; toolCallLog: ToolCallLogEntry[]; } export function assembleKicadResult(orch: OrchestrationResult): KicadResult { let schematicSvg: string | null = null; let boardSvg: string | null = null; let gerberUrl: string | null = null; let bomUrl: string | null = null; let pdfUrl: string | null = null; let drcResults: { violations: string[] } | null = null; for (const entry of orch.toolCallLog) { try { const parsed = JSON.parse(entry.result); switch (entry.tool) { case "export_svg": // Could be schematic or board SVG — check args or content if (entry.args.type === "board" || entry.args.board) { boardSvg = parsed.svg_path || parsed.path || parsed.url || null; } else { schematicSvg = parsed.svg_path || parsed.path || parsed.url || null; } break; case "run_drc": drcResults = { violations: parsed.violations || parsed.errors || [], }; break; case "export_gerber": gerberUrl = parsed.gerber_path || parsed.path || parsed.url || null; break; case "export_bom": bomUrl = parsed.bom_path || parsed.path || parsed.url || null; break; case "export_pdf": pdfUrl = parsed.pdf_path || parsed.path || parsed.url || null; break; } } catch { // Non-JSON results are fine (intermediate steps) } } return { schematicSvg, boardSvg, gerberUrl, bomUrl, pdfUrl, drcResults, summary: orch.finalMessage, toolCallLog: orch.toolCallLog, }; } export interface FreecadResult { previewUrl: string | null; stepUrl: string | null; stlUrl: string | null; summary: string; toolCallLog: ToolCallLogEntry[]; } export function assembleFreecadResult(orch: OrchestrationResult): FreecadResult { let previewUrl: string | null = null; let stepUrl: string | null = null; let stlUrl: string | null = null; for (const entry of orch.toolCallLog) { try { const parsed = JSON.parse(entry.result); // FreeCAD exports via execute_python_script — look for file paths in results if (entry.tool === "execute_python_script" || entry.tool === "execute_script") { const text = entry.result.toLowerCase(); if (text.includes(".step") || text.includes(".stp")) { stepUrl = parsed.path || parsed.file_path || extractPathFromText(entry.result, [".step", ".stp"]); } if (text.includes(".stl")) { stlUrl = parsed.path || parsed.file_path || extractPathFromText(entry.result, [".stl"]); } } // save_document may also produce a path if (entry.tool === "save_document") { const path = parsed.path || parsed.file_path || null; if (path && (path.endsWith(".FCStd") || path.endsWith(".fcstd"))) { // Not directly servable, but note it } } } catch { // Try extracting paths from raw text stepUrl = stepUrl || extractPathFromText(entry.result, [".step", ".stp"]); stlUrl = stlUrl || extractPathFromText(entry.result, [".stl"]); } } return { previewUrl, stepUrl, stlUrl, summary: orch.finalMessage, toolCallLog: orch.toolCallLog, }; } /** Extract a file path ending with one of the given extensions from text */ function extractPathFromText(text: string, extensions: string[]): string | null { for (const ext of extensions) { const regex = new RegExp(`(/[\\w./-]+${ext.replace(".", "\\.")})`, "i"); const match = text.match(regex); if (match) return match[1]; } return null; } // ── System prompts ── 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 /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 5. add_schematic_connection — Wire components together 6. generate_netlist — Generate the netlist from schematic 7. place_component — Place footprints on the board 8. route_trace — Route traces between pads 9. export_svg — Export schematic SVG (type: "schematic") and board SVG (type: "board") 10. run_drc — Run Design Rule Check 11. export_gerber, export_bom, export_pdf — Generate manufacturing outputs Important: - 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 - After placing components, route critical traces - Always run DRC before exporting - If a tool call fails, try an alternative approach rather than repeating the same call`; 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("/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], "/data/files/generated/freecad-/model.step") 7. execute_python_script to export STL: Mesh.export([obj], "/data/files/generated/freecad-/model.stl") Important: - 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 - Always export both STEP (for CAD) and STL (for 3D printing) - If a tool call fails, try an alternative approach`;