/**
* 3D MCP Server — AI-driven 3D model generation
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import {
Scene, Vec3,
cubeScad, sphereScad, cylinderScad, coneScad, torusScad, polyhedronScad,
text3dScad,
translateScad, rotateScad, scaleScad, mirrorScad, colorScad,
booleanScad,
linearExtrudeScad, rotateExtrudeScad,
circleScad, squareScad, polygonScad,
} from './scene.js';
import {
checkOpenScad, saveScadFile, exportModel, renderPreview,
} from './openscad.js';
export class ThreeDMCPServer {
private server: McpServer;
private scene: Scene;
constructor() {
this.scene = new Scene();
this.server = new McpServer({
name: '3d-mcp-server',
version: '1.0.0',
});
this.registerTools();
}
private registerTools(): void {
// --- Status ---
this.server.tool(
'status',
'Check server status, OpenSCAD installation, and current scene info',
{},
async () => {
const scadCheck = await checkOpenScad();
const info = {
openscad: scadCheck,
scene: {
totalObjects: this.scene.objectCount,
rootObjects: this.scene.rootCount,
objects: this.scene.listObjects().map(o => ({
id: o.id,
name: o.name,
type: o.type,
})),
},
};
return { content: [{ type: 'text', text: JSON.stringify(info, null, 2) }] };
}
);
// --- Create Primitive ---
this.server.tool(
'create_primitive',
'Create a 3D primitive shape (cube, sphere, cylinder, cone, torus)',
{
shape: z.enum(['cube', 'sphere', 'cylinder', 'cone', 'torus']).describe('Shape type'),
name: z.string().optional().describe('Name for the object'),
// Cube params
width: z.number().optional().describe('Width (X) for cube'),
height: z.number().optional().describe('Height (Z) for cube/cylinder/cone'),
depth: z.number().optional().describe('Depth (Y) for cube'),
// Sphere/cylinder params
radius: z.number().optional().describe('Radius for sphere/cylinder/cone/torus'),
radius2: z.number().optional().describe('Top radius for tapered cylinder'),
// Torus params
minorRadius: z.number().optional().describe('Minor (tube) radius for torus'),
// General
center: z.boolean().optional().describe('Center the object at origin'),
resolution: z.number().optional().describe('Resolution ($fn), default 32'),
},
async (params) => {
const fn = params.resolution ?? 32;
const center = params.center ?? true;
let scadCode: string;
const shape = params.shape;
switch (shape) {
case 'cube': {
const w = params.width ?? 10;
const h = params.height ?? 10;
const d = params.depth ?? 10;
scadCode = cubeScad([w, d, h], center);
break;
}
case 'sphere': {
scadCode = sphereScad(params.radius ?? 10, fn);
break;
}
case 'cylinder': {
scadCode = cylinderScad(
params.height ?? 10,
params.radius ?? 5,
params.radius2,
fn,
center
);
break;
}
case 'cone': {
scadCode = coneScad(params.height ?? 10, params.radius ?? 5, fn, center);
break;
}
case 'torus': {
scadCode = torusScad(
params.radius ?? 10,
params.minorRadius ?? 3,
fn
);
break;
}
default:
return { content: [{ type: 'text', text: `Unknown shape: ${shape}` }] };
}
const obj = this.scene.addObject({
name: params.name ?? shape,
type: 'primitive',
scadCode,
children: [],
metadata: { shape, params },
});
return {
content: [{
type: 'text',
text: `Created ${shape} "${obj.name}" (id: ${obj.id})\n\nOpenSCAD:\n${scadCode}`,
}],
};
}
);
// --- Create Text ---
this.server.tool(
'create_text',
'Create 3D extruded text',
{
text: z.string().describe('Text to render'),
name: z.string().optional().describe('Name for the object'),
size: z.number().optional().describe('Font size (default 10)'),
height: z.number().optional().describe('Extrusion height (default 2)'),
font: z.string().optional().describe('Font name (default "Liberation Sans")'),
},
async (params) => {
const scadCode = text3dScad(
params.text,
params.size ?? 10,
params.height ?? 2,
params.font ?? 'Liberation Sans'
);
const obj = this.scene.addObject({
name: params.name ?? `text:${params.text}`,
type: 'text3d',
scadCode,
children: [],
metadata: { text: params.text },
});
return {
content: [{
type: 'text',
text: `Created 3D text "${params.text}" (id: ${obj.id})\n\nOpenSCAD:\n${scadCode}`,
}],
};
}
);
// --- Transform ---
this.server.tool(
'transform',
'Apply a transformation to an object (translate, rotate, scale, mirror, color)',
{
objectId: z.string().describe('ID of the object to transform'),
operation: z.enum(['translate', 'rotate', 'scale', 'mirror', 'color']).describe('Transform type'),
x: z.number().optional().describe('X value'),
y: z.number().optional().describe('Y value'),
z: z.number().optional().describe('Z value'),
color: z.string().optional().describe('Color name (e.g., "red", "blue", "#ff0000")'),
alpha: z.number().optional().describe('Opacity 0-1 (default 1)'),
name: z.string().optional().describe('Name for the result'),
},
async (params) => {
const target = this.scene.getObject(params.objectId);
if (!target) {
return { content: [{ type: 'text', text: `Object ${params.objectId} not found` }] };
}
const vec: Vec3 = [params.x ?? 0, params.y ?? 0, params.z ?? 0];
let scadCode: string;
switch (params.operation) {
case 'translate':
scadCode = translateScad(vec, target.scadCode);
break;
case 'rotate':
scadCode = rotateScad(vec, target.scadCode);
break;
case 'scale':
scadCode = scaleScad([params.x ?? 1, params.y ?? 1, params.z ?? 1], target.scadCode);
break;
case 'mirror':
scadCode = mirrorScad(vec, target.scadCode);
break;
case 'color':
scadCode = colorScad(params.color ?? 'gray', params.alpha ?? 1, target.scadCode);
break;
default:
return { content: [{ type: 'text', text: `Unknown operation: ${params.operation}` }] };
}
const obj = this.scene.addObject({
name: params.name ?? `${params.operation}(${target.name})`,
type: 'transform',
scadCode,
children: [params.objectId],
metadata: { operation: params.operation, target: params.objectId },
});
return {
content: [{
type: 'text',
text: `Applied ${params.operation} to "${target.name}" → "${obj.name}" (id: ${obj.id})\n\nOpenSCAD:\n${scadCode}`,
}],
};
}
);
// --- Boolean Operation ---
this.server.tool(
'boolean_op',
'Combine objects with boolean operations (union, difference, intersection)',
{
operation: z.enum(['union', 'difference', 'intersection']).describe('Boolean operation'),
objectIds: z.array(z.string()).min(2).describe('IDs of objects to combine (order matters for difference)'),
name: z.string().optional().describe('Name for the result'),
},
async (params) => {
const children: string[] = [];
const childCodes: string[] = [];
for (const id of params.objectIds) {
const obj = this.scene.getObject(id);
if (!obj) {
return { content: [{ type: 'text', text: `Object ${id} not found` }] };
}
children.push(id);
childCodes.push(obj.scadCode);
}
const scadCode = booleanScad(params.operation, childCodes);
const obj = this.scene.addObject({
name: params.name ?? `${params.operation}(${children.length} objects)`,
type: 'boolean',
scadCode,
children,
metadata: { operation: params.operation },
});
return {
content: [{
type: 'text',
text: `Created ${params.operation} of ${children.length} objects → "${obj.name}" (id: ${obj.id})\n\nOpenSCAD:\n${scadCode}`,
}],
};
}
);
// --- Linear Extrude ---
this.server.tool(
'linear_extrude',
'Extrude a 2D shape (circle, square, polygon) into 3D',
{
shape: z.enum(['circle', 'square', 'polygon']).describe('2D shape to extrude'),
height: z.number().describe('Extrusion height'),
twist: z.number().optional().describe('Twist angle in degrees'),
scale: z.number().optional().describe('Scale factor at top (1 = same size)'),
// Circle
radius: z.number().optional().describe('Radius for circle'),
// Square
width: z.number().optional().describe('Width for square'),
depth: z.number().optional().describe('Depth for square'),
// Polygon
points: z.array(z.tuple([z.number(), z.number()])).optional().describe('Points for polygon [[x,y], ...]'),
name: z.string().optional().describe('Name for the object'),
},
async (params) => {
let shape2d: string;
switch (params.shape) {
case 'circle':
shape2d = circleScad(params.radius ?? 5);
break;
case 'square':
shape2d = squareScad([params.width ?? 10, params.depth ?? 10]);
break;
case 'polygon':
if (!params.points?.length) {
return { content: [{ type: 'text', text: 'Polygon requires points' }] };
}
shape2d = polygonScad(params.points);
break;
default:
return { content: [{ type: 'text', text: `Unknown shape: ${params.shape}` }] };
}
const scadCode = linearExtrudeScad(
params.height,
params.twist ?? 0,
params.scale ?? 1,
32,
shape2d
);
const obj = this.scene.addObject({
name: params.name ?? `extrude(${params.shape})`,
type: 'extrude',
scadCode,
children: [],
metadata: { shape: params.shape },
});
return {
content: [{
type: 'text',
text: `Created extruded ${params.shape} → "${obj.name}" (id: ${obj.id})\n\nOpenSCAD:\n${scadCode}`,
}],
};
}
);
// --- Rotate Extrude ---
this.server.tool(
'rotate_extrude',
'Create a solid of revolution by rotating a 2D profile around the Z axis',
{
shape: z.enum(['circle', 'square', 'polygon']).describe('2D profile shape'),
angle: z.number().optional().describe('Rotation angle in degrees (default 360)'),
offsetX: z.number().describe('Distance from Z axis to profile center'),
radius: z.number().optional().describe('Radius for circle profile'),
width: z.number().optional().describe('Width for square profile'),
depth: z.number().optional().describe('Height for square profile'),
points: z.array(z.tuple([z.number(), z.number()])).optional().describe('Points for polygon'),
name: z.string().optional().describe('Name for the object'),
},
async (params) => {
let shape2d: string;
switch (params.shape) {
case 'circle':
shape2d = circleScad(params.radius ?? 3);
break;
case 'square':
shape2d = squareScad([params.width ?? 5, params.depth ?? 5]);
break;
case 'polygon':
if (!params.points?.length) {
return { content: [{ type: 'text', text: 'Polygon requires points' }] };
}
shape2d = polygonScad(params.points);
break;
default:
return { content: [{ type: 'text', text: `Unknown shape: ${params.shape}` }] };
}
// Wrap with translate to offset from axis
const translated = `translate([${params.offsetX}, 0, 0])\n ${shape2d}`;
const scadCode = rotateExtrudeScad(params.angle ?? 360, 32, translated);
const obj = this.scene.addObject({
name: params.name ?? `revolve(${params.shape})`,
type: 'extrude',
scadCode,
children: [],
metadata: { shape: params.shape, angle: params.angle },
});
return {
content: [{
type: 'text',
text: `Created revolved ${params.shape} → "${obj.name}" (id: ${obj.id})\n\nOpenSCAD:\n${scadCode}`,
}],
};
}
);
// --- Custom SCAD ---
this.server.tool(
'custom_scad',
'Add raw OpenSCAD code directly for complex shapes not covered by other tools',
{
code: z.string().describe('Raw OpenSCAD code'),
name: z.string().optional().describe('Name for the object'),
},
async (params) => {
const obj = this.scene.addObject({
name: params.name ?? 'custom',
type: 'primitive',
scadCode: params.code,
children: [],
metadata: { custom: true },
});
return {
content: [{
type: 'text',
text: `Added custom SCAD code → "${obj.name}" (id: ${obj.id})`,
}],
};
}
);
// --- Scene Management ---
this.server.tool(
'scene_list',
'List all objects in the current scene',
{},
async () => {
const objects = this.scene.listObjects();
if (objects.length === 0) {
return { content: [{ type: 'text', text: 'Scene is empty. Use create_primitive to add objects.' }] };
}
const list = objects.map(o =>
`• ${o.id} — "${o.name}" (${o.type})${o.children.length ? ` [children: ${o.children.join(', ')}]` : ''}`
).join('\n');
return {
content: [{
type: 'text',
text: `Scene: ${objects.length} objects\n\n${list}`,
}],
};
}
);
this.server.tool(
'scene_clear',
'Clear all objects from the scene',
{},
async () => {
const count = this.scene.objectCount;
this.scene.clear();
return { content: [{ type: 'text', text: `Cleared ${count} objects from scene.` }] };
}
);
this.server.tool(
'remove_object',
'Remove an object from the scene by ID',
{
objectId: z.string().describe('ID of the object to remove'),
},
async (params) => {
const removed = this.scene.removeObject(params.objectId);
return {
content: [{
type: 'text',
text: removed
? `Removed object ${params.objectId}`
: `Object ${params.objectId} not found`,
}],
};
}
);
// --- Get SCAD Code ---
this.server.tool(
'get_scad',
'Get the full OpenSCAD source code for the current scene',
{},
async () => {
const code = this.scene.toScad();
return { content: [{ type: 'text', text: code }] };
}
);
// --- Save SCAD File ---
this.server.tool(
'save_scad',
'Save the scene as an OpenSCAD .scad file',
{
filename: z.string().optional().describe('Output filename (default: model-<timestamp>.scad)'),
},
async (params) => {
const code = this.scene.toScad();
const filePath = await saveScadFile(code, params.filename);
return {
content: [{
type: 'text',
text: `Saved SCAD file: ${filePath}\n\n${code}`,
}],
};
}
);
// --- Export (STL, OBJ, etc.) ---
this.server.tool(
'export',
'Export the scene to STL, OBJ, 3MF, AMF, or other formats (requires OpenSCAD)',
{
format: z.enum(['stl', 'obj', '3mf', 'amf', 'off', 'dxf', 'svg', 'csg']).optional()
.describe('Export format (default: stl)'),
filename: z.string().optional().describe('Output filename'),
},
async (params) => {
const code = this.scene.toScad();
const format = params.format ?? 'stl';
const result = await exportModel(code, format, params.filename);
if (result.success) {
return {
content: [{
type: 'text',
text: `✅ Exported to ${format.toUpperCase()}: ${result.outputPath}\nDuration: ${result.durationMs}ms`,
}],
};
} else {
return {
content: [{
type: 'text',
text: `❌ Export failed: ${result.stderr}`,
}],
};
}
}
);
// --- Preview (render PNG) ---
this.server.tool(
'preview',
'Render a PNG preview image of the current scene (requires OpenSCAD)',
{
width: z.number().optional().describe('Image width in pixels (default 800)'),
height: z.number().optional().describe('Image height in pixels (default 600)'),
camera: z.string().optional().describe('Camera position: "transX,Y,Z,rotX,Y,Z,dist"'),
projection: z.enum(['perspective', 'orthogonal']).optional().describe('Projection type'),
},
async (params) => {
const code = this.scene.toScad();
const result = await renderPreview(code, {
width: params.width,
height: params.height,
camera: params.camera,
projection: params.projection,
});
if (result.success && result.imageBase64) {
return {
content: [
{ type: 'text', text: `✅ Preview rendered: ${result.imagePath} (${result.durationMs}ms)` },
{ type: 'image', data: result.imageBase64, mimeType: 'image/png' },
],
};
} else {
return {
content: [{
type: 'text',
text: `❌ Preview failed: ${result.stderr}`,
}],
};
}
}
);
// --- Polyhedron (advanced) ---
this.server.tool(
'create_polyhedron',
'Create a custom polyhedron from vertices and faces',
{
points: z.array(z.tuple([z.number(), z.number(), z.number()])).describe('3D vertices [[x,y,z], ...]'),
faces: z.array(z.array(z.number())).describe('Face definitions [[v0,v1,v2,...], ...]'),
name: z.string().optional().describe('Name for the object'),
},
async (params) => {
const scadCode = polyhedronScad(params.points, params.faces);
const obj = this.scene.addObject({
name: params.name ?? 'polyhedron',
type: 'primitive',
scadCode,
children: [],
metadata: { vertices: params.points.length, faces: params.faces.length },
});
return {
content: [{
type: 'text',
text: `Created polyhedron "${obj.name}" (id: ${obj.id}) — ${params.points.length} vertices, ${params.faces.length} faces`,
}],
};
}
);
}
async start(): Promise<void> {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('🎨 3D MCP Server started');
console.error('🔧 Tools: create_primitive, boolean_op, transform, export, preview, ...');
}
}