Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"mcpServers": {
"makerjs-playground": {
"command": "node",
"args": ["tools/playground-mcp/server.mjs"],
"env": {
"PLAYGROUND_PORT": "8020"
}
}
}
}
123 changes: 123 additions & 0 deletions docs/demos/js/mcp/current.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
var makerjs = require('makerjs');

function Wardrobe(totalW, totalH, baseH, topH, thk, depth, spacing) {
this.models = {};

var mainH = totalH - baseH - topH;
var halfW = totalW / 2;

function makeRect(w, h, layer) {
var m = new makerjs.models.Rectangle(w, h);
if (layer) m.layer = layer;
return m;
}

function makePanel(x, y, w, h, layer) {
var p = makeRect(w, h, layer);
return makerjs.model.move(p, [x, y]);
}

var front = { models: {}, paths: {} };

front.models.base = makePanel(0, 0, totalW, baseH);
front.models.body = makePanel(0, baseH, totalW, mainH);
front.models.top = makePanel(0, baseH + mainH, totalW, topH);

front.paths.splitLine = new makerjs.paths.Line([halfW, 0], [halfW, totalH]);

var handleH = 100;
var handleW = 6;
var handleOff = 12;

front.models.hLeft = makePanel(halfW - handleOff - handleW, baseH, handleW, handleH);
front.models.hRight = makePanel(halfW + handleOff, baseH, handleW, handleH);
front.models.rightTrim = makePanel(halfW + handleOff, baseH + handleH - 15, halfW - handleOff, 15);
front.models.rightPanel = makePanel(halfW + handleOff, baseH + handleH - 30, halfW - handleOff, 15);

this.models.frontElevation = front;

var internal = { models: {}, paths: {} };
var ix = totalW + spacing;

internal.models.base = makePanel(ix, 0, totalW, baseH);

internal.models.topOuter = makePanel(ix, baseH + mainH, totalW, topH);
internal.models.topDiv = makePanel(ix + halfW - thk / 2, baseH + mainH, thk, topH);

internal.models.mainOuter = makePanel(ix, baseH, totalW, mainH);
internal.models.mainDiv = makePanel(ix + halfW - thk / 2, baseH, thk, mainH);

var rightInnerW = halfW - thk * 1.5;
var subBayW = rightInnerW / 2;
var drawerH = 18;
var drawerAreaH = drawerH * 3 + thk * 3;

internal.models.subDiv = makePanel(ix + halfW + subBayW, baseH, thk, drawerAreaH);

for (var i = 0; i < 3; i++) {
var dy = baseH + (i + 1) * (drawerH + thk);
internal.models['drawerShelf_' + i] = makePanel(ix + halfW + subBayW, dy, subBayW + thk / 2, thk);
}

var rodMargin = 3;
var topRodY = baseH + mainH - 12;
var midRodY = baseH + drawerAreaH + 5;

function addRod(name, x, y, len) {
var rod = {
layer: 'rod',
paths: {
top: new makerjs.paths.Line([x, y + 1.5], [x + len, y + 1.5]),
bot: new makerjs.paths.Line([x, y - 1.5], [x + len, y - 1.5]),
left: new makerjs.paths.Line([x, y - 1.5], [x, y + 1.5]),
right: new makerjs.paths.Line([x + len, y - 1.5], [x + len, y + 1.5])
}
};
internal.models[name] = rod;
}

addRod('rodL_top', ix + thk + rodMargin, topRodY, halfW - thk * 1.5 - rodMargin * 2);
addRod('rodL_mid', ix + thk + rodMargin, midRodY, halfW - thk * 1.5 - rodMargin * 2);

addRod('rodR_top', ix + halfW + thk / 2 + rodMargin, topRodY, halfW - thk * 1.5 - rodMargin * 2);
addRod('rodR_mid', ix + halfW + thk / 2 + rodMargin, midRodY, subBayW - rodMargin * 2);

this.models.internalElevation = internal;

var sx1 = ix + totalW + spacing;
var sx2 = sx1 + depth + spacing * 0.8;

function makeSideSection(xOffset, isRightBay) {
var side = { models: {} };

side.models.base = makePanel(xOffset, 0, depth, baseH);
side.models.main = makePanel(xOffset, baseH, depth, mainH);
side.models.top = makePanel(xOffset, baseH + mainH, depth, topH);

if (isRightBay) {
for (var j = 1; j <= 3; j++) {
var shelfY = baseH + j * (drawerH + thk);
side.models['sideShelf_' + j] = makePanel(xOffset + thk, shelfY, depth - thk * 2, thk);
}
}

return side;
}

this.models.sideSectionLeft = makeSideSection(sx1, false);
this.models.sideSectionRight = makeSideSection(sx2, true);

this.notes = '# Wardrobe elevations\nFront / internal / side-section views. Tested via makerjs-playground MCP render_model: 117 paths, 33 models, 28 chains, 614 x 271.5.';
}

Wardrobe.metaParameters = [
{ title: "Total Width (W)", type: "range", min: 120, max: 300, value: 200 },
{ title: "Total Height (H)", type: "range", min: 200, max: 300, value: 271.5 },
{ title: "Base Height", type: "range", min: 5, max: 15, value: 10 },
{ title: "Top Box Height", type: "range", min: 30, max: 70, value: 48.5 },
{ title: "Board Thickness", type: "range", min: 1.2, max: 2.5, step: 0.1, value: 1.8 },
{ title: "Cabinet Depth", type: "range", min: 40, max: 70, value: 58 },
{ title: "View Spacing", type: "range", min: 10, max: 60, value: 35 }
];

module.exports = Wardrobe;
3 changes: 3 additions & 0 deletions tools/playground-mcp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
workspace/
*.log
99 changes: 99 additions & 0 deletions tools/playground-mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# makerjs-playground MCP server

An [MCP](https://modelcontextprotocol.io) server that gives an AI agent (e.g. Claude Code)
a fast **edit → render → inspect → fix** loop for **Maker.js "IModel" JavaScript** — the
code in the right-hand editor of the [Maker.js playground](https://maker.js.org/playground/).

## Why

Iterating on playground code normally means: type code in a browser, click *Run*, eyeball
the SVG, guess what broke. This server lets the agent do the same loop headlessly and
deterministically (real `makerjs` in Node, structured errors with line/column), then push
a finished drawing into the live browser playground for a human to look at.

## Install

```sh
cd tools/playground-mcp
npm install
npm run selftest # 27 checks, exits 0 on success
```

## Register with Claude Code

`.mcp.json` (already in the repo root and in the parent working dir):

```json
{
"mcpServers": {
"makerjs-playground": {
"command": "node",
"args": ["tools/playground-mcp/server.mjs"],
"env": { "PLAYGROUND_PORT": "8020" }
}
}
}
```

Then in Claude Code: `/mcp` to confirm `makerjs-playground` is connected.

## Tools

| Tool | What it does |
|---|---|
| `render_model` | Run IModel JS headlessly. Returns `{ok, kind, extents, stats:{pathCount,modelCount,chainCount}, console, outputs:{svg,…}}` or `{ok:false, phase, error:{name,message,line,column,stack}}`. Params: `code`, `params?`, `exports?` (`svg dxf json pathdata openjscad stl`), `svgOptions?`. **The inner dev loop — no browser involved.** |
| `set_playground_code` | Validate via render, then write to `docs/demos/js/mcp/current.js`. Returns `mcpUrl`. Refuses broken code unless `force:true`. |
| `get_playground_code` | Read that file back. |
| `playground_start` / `playground_stop` / `playground_status` | Manage a static `http-server` (`-c-1`, no cache) for the repo. |
| `list_models` | Every `makerjs.models.*` constructor + its `metaParameters` + defaults. |
| `list_examples` / `get_example` | Browse / fetch `docs/demos/js/*.js` as reference. |
| `makerjs_api` | Shallow index of the `makerjs` module (namespaces, member names, arity). |

## Browser sync

`set_playground_code` writes `docs/demos/js/mcp/current.js`. Open

```
http://localhost:8020/docs/playground/?script=mcp/current
```

The playground fetches that file into the editor and runs it. Reload the page after each
`set_playground_code` to see the update (the server sends `Cache-Control` off, so a plain
reload is enough).

## IModel code shape

```js
var makerjs = require('makerjs');

// (a) constructor style — `this` is the model
this.paths = { c: new makerjs.paths.Circle([0,0], 25) };
this.models = { r: new makerjs.models.Rectangle(50, 20) };
this.notes = '# markdown notes';
```

or

```js
var makerjs = require('makerjs');
function widget(w, h) { this.paths = makerjs.model.originate({ /* … */ }); }
widget.metaParameters = [
{ title: 'width', type: 'range', min: 10, max: 100, value: 50 },
{ title: 'height', type: 'range', min: 10, max: 100, value: 20 }
];
module.exports = widget; // a "kit" — render_model constructs it from metaParameters (or your `params`)
```

`require()` only resolves `'makerjs'` (and built-in model names).

## Layout

```
tools/playground-mcp/
server.mjs MCP server (stdio), 10 tools
lib/render.mjs headless Maker.js execution + measure + export + error locating
lib/playgroundServer.mjs http-server child-process control
lib/state.mjs shared code file + demo browsing
test/selftest.mjs 27-check automated verification
docs/demos/js/mcp/current.js the code the browser playground loads via ?script=mcp/current
```
Binary file added tools/playground-mcp/docs/verify-playground.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
133 changes: 133 additions & 0 deletions tools/playground-mcp/lib/playgroundServer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Controls a static http-server child process that serves the Maker.js repo so
// the playground is reachable at http://localhost:<port>/docs/playground/
//
// Caching is disabled (-c-1) so that code written by set_playground_code shows
// up on a simple browser reload.

import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const require = createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url));

// tools/playground-mcp/lib -> repo root
export const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
export const PLAYGROUND_PATH = '/docs/playground/';
// The playground resolves ?script=<id> against MakerJsPlayground.relativePath,
// which docs/playground/index.html sets to '../demos/js/'. So script id
// 'mcp/current' -> docs/demos/js/mcp/current.js (served at /docs/demos/js/mcp/current.js).
export const MCP_SCRIPT_ID = 'mcp/current';
export const MCP_CODE_FILE = path.join(REPO_ROOT, 'docs', 'demos', 'js', 'mcp', 'current.js');
export const MCP_CODE_URL_PATH = '/docs/demos/js/mcp/current.js';

const DEFAULT_PORT = Number(process.env.PLAYGROUND_PORT || 8020);

let child = null;
let currentPort = null;
let startedAt = null;
let lastStdout = '';

function httpServerBin() {
// resolve the JS entrypoint so we can run it with the current node (Windows-safe)
return require.resolve('http-server/bin/http-server');
}

export function playgroundUrl(port = currentPort || DEFAULT_PORT, scriptId) {
const base = `http://localhost:${port}${PLAYGROUND_PATH}`;
// script ids are simple path-like tokens (e.g. "mcp/current"); keep "/" readable
return scriptId ? `${base}?script=${encodeURIComponent(scriptId).replace(/%2F/gi, '/')}` : base;
}

export async function isPortAlive(port) {
try {
const res = await fetch(`http://localhost:${port}${PLAYGROUND_PATH}`, {
signal: AbortSignal.timeout(1500),
});
return res.ok;
} catch {
return false;
}
}

export async function startPlayground({ port = DEFAULT_PORT } = {}) {
if (child && !child.killed) {
return {
alreadyRunning: true,
pid: child.pid,
port: currentPort,
url: playgroundUrl(currentPort),
mcpUrl: playgroundUrl(currentPort, MCP_SCRIPT_ID),
};
}

// Someone else may already be serving it.
if (await isPortAlive(port)) {
currentPort = port;
startedAt = Date.now();
return {
alreadyRunning: true,
external: true,
port,
url: playgroundUrl(port),
mcpUrl: playgroundUrl(port, MCP_SCRIPT_ID),
};
}

const args = [httpServerBin(), REPO_ROOT, '-p', String(port), '-c-1', '--silent'];
child = spawn(process.execPath, args, {
cwd: REPO_ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
currentPort = port;
startedAt = Date.now();
lastStdout = '';
child.stdout.on('data', d => { lastStdout = (lastStdout + d).slice(-2000); });
child.stderr.on('data', d => { lastStdout = (lastStdout + d).slice(-2000); });
child.on('exit', () => { child = null; currentPort = null; startedAt = null; });

// wait for it to answer
const deadline = Date.now() + 8000;
while (Date.now() < deadline) {
if (await isPortAlive(port)) {
return {
started: true,
pid: child.pid,
port,
url: playgroundUrl(port),
mcpUrl: playgroundUrl(port, MCP_SCRIPT_ID),
serves: REPO_ROOT,
};
}
await new Promise(r => setTimeout(r, 200));
}
throw new Error(`http-server did not come up on port ${port} within 8s. Output:\n${lastStdout}`);
}

export function stopPlayground() {
if (!child || child.killed) return { stopped: false, reason: 'not running (or started externally)' };
const pid = child.pid;
child.kill();
child = null;
currentPort = null;
startedAt = null;
return { stopped: true, pid };
}

export async function playgroundStatus() {
const managed = !!(child && !child.killed);
const port = currentPort || DEFAULT_PORT;
const alive = await isPortAlive(port);
return {
running: alive,
managedByThisServer: managed,
pid: managed ? child.pid : null,
port,
uptimeSeconds: startedAt ? Math.round((Date.now() - startedAt) / 1000) : null,
url: playgroundUrl(port),
mcpUrl: playgroundUrl(port, MCP_SCRIPT_ID),
repoRoot: REPO_ROOT,
};
}
Loading