325 lines
10 KiB
TypeScript
325 lines
10 KiB
TypeScript
/**
|
|
* 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<string, any>;
|
|
result: string;
|
|
}
|
|
|
|
export interface OrchestrationResult {
|
|
artifacts: Map<string, string>;
|
|
finalMessage: string;
|
|
toolCallLog: ToolCallLogEntry[];
|
|
}
|
|
|
|
export async function runCadAgentLoop(
|
|
client: Client,
|
|
systemPrompt: string,
|
|
userPrompt: string,
|
|
geminiApiKey: string,
|
|
maxTurns = 8,
|
|
): Promise<OrchestrationResult> {
|
|
// 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<string, string>();
|
|
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<string, unknown>,
|
|
});
|
|
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 /tmp/kicad-gen-<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
|
|
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 /tmp/kicad-gen-${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("/tmp/freecad-gen-<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")
|
|
|
|
Important:
|
|
- Use /tmp/freecad-gen-${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`;
|