@classcad/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +361 -0
  3. package/dist/auth.d.ts +32 -0
  4. package/dist/auth.js +272 -0
  5. package/dist/auth.js.map +1 -0
  6. package/dist/bridge/protocol.d.ts +87 -0
  7. package/dist/bridge/protocol.js +14 -0
  8. package/dist/bridge/protocol.js.map +1 -0
  9. package/dist/bridge/server.d.ts +35 -0
  10. package/dist/bridge/server.js +236 -0
  11. package/dist/bridge/server.js.map +1 -0
  12. package/dist/client.d.ts +98 -0
  13. package/dist/client.js +879 -0
  14. package/dist/client.js.map +1 -0
  15. package/dist/daemon.d.ts +38 -0
  16. package/dist/daemon.js +390 -0
  17. package/dist/daemon.js.map +1 -0
  18. package/dist/engine/wasm-worker.d.ts +1 -0
  19. package/dist/engine/wasm-worker.js +176 -0
  20. package/dist/engine/wasm-worker.js.map +1 -0
  21. package/dist/engine/wasm.d.ts +60 -0
  22. package/dist/engine/wasm.js +212 -0
  23. package/dist/engine/wasm.js.map +1 -0
  24. package/dist/mcp-server.d.ts +23 -0
  25. package/dist/mcp-server.js +141 -0
  26. package/dist/mcp-server.js.map +1 -0
  27. package/dist/ports.d.ts +2 -0
  28. package/dist/ports.js +5 -0
  29. package/dist/ports.js.map +1 -0
  30. package/dist/queue.d.ts +9 -0
  31. package/dist/queue.js +50 -0
  32. package/dist/queue.js.map +1 -0
  33. package/dist/server.d.ts +2 -0
  34. package/dist/server.js +423 -0
  35. package/dist/server.js.map +1 -0
  36. package/dist/tools/auth.d.ts +7 -0
  37. package/dist/tools/auth.js +63 -0
  38. package/dist/tools/auth.js.map +1 -0
  39. package/dist/tools/bridge.d.ts +4 -0
  40. package/dist/tools/bridge.js +115 -0
  41. package/dist/tools/bridge.js.map +1 -0
  42. package/dist/tools/docs.d.ts +13 -0
  43. package/dist/tools/docs.js +180 -0
  44. package/dist/tools/docs.js.map +1 -0
  45. package/dist/tools/lifecycle.d.ts +3 -0
  46. package/dist/tools/lifecycle.js +156 -0
  47. package/dist/tools/lifecycle.js.map +1 -0
  48. package/dist/tools/script.d.ts +3 -0
  49. package/dist/tools/script.js +102 -0
  50. package/dist/tools/script.js.map +1 -0
  51. package/dist/tools/snapshot.d.ts +3 -0
  52. package/dist/tools/snapshot.js +174 -0
  53. package/dist/tools/snapshot.js.map +1 -0
  54. package/dist/tools/state.d.ts +3 -0
  55. package/dist/tools/state.js +95 -0
  56. package/dist/tools/state.js.map +1 -0
  57. package/dist/types.d.ts +46 -0
  58. package/dist/types.js +3 -0
  59. package/dist/types.js.map +1 -0
  60. package/package.json +44 -0
package/dist/client.js ADDED
@@ -0,0 +1,879 @@
1
+ import { PendingRequests, completeGraphic, normalizeResult, withEmissionOverride } from '@classcad/script';
2
+ // client.ts — ClassCAD WebSocket client (TS port of scripts/client.mjs).
3
+ //
4
+ // Connects to a Drogon WS server and exposes promise-based execute()/request()
5
+ // helpers plus a cached structure tree / graphic that MCP tools read.
6
+ //
7
+ // ── Emission model (per CONNECTION, suppression per SCRIPT) ──────────────────
8
+ // The engine keeps ONE CommandConfig per connection (a per-request `config`
9
+ // field is ignored). This client does NOT touch it on connect: the MCP may
10
+ // dock into a session shared with an interactive app (invite URL), and the
11
+ // server broadcasts what the ORIGINATOR of a command emits - a permanently
12
+ // suppressed MCP would mutate the model without the app ever seeing it. So
13
+ // the connection runs with the engine's defaults (full content, bundled);
14
+ // run_script suppresses payloads for the duration of ONE script (runScript
15
+ // in @classcad/script reads, switches, restores) and pulls once afterwards
16
+ // so the app and the caches converge.
17
+ // • pull() → GetTree. The structure ALWAYS comes back (GetTree is an
18
+ // explicit request); inside a suppressed script the pull switches
19
+ // sendGraphic_Kernel on around it (PULL_GRAPHIC_ON) and back.
20
+ // Caches: a mutation counter makes them self-invalidating; a Result that
21
+ // carries the structure keeps the tree cache current without a pull, the
22
+ // complete graphic only ever comes from a pull.
23
+ //
24
+ // Supports reconnecting with a different ClassCAD-Session-Id header at runtime
25
+ // via reconnect(sessionId) — used by the use_session MCP tool.
26
+ //
27
+ // ── Three engines, one client ────────────────────────────────────────────────
28
+ // ws a ClassCAD worker (Drogon) over WebSocket - own session or a shared
29
+ // one (?invite=). The default.
30
+ // bridge an app's in-page WASM engine reached through the bridge listener
31
+ // (?bridge= share link); commands are relayed to the page.
32
+ // wasm the MCP's OWN engine: the published WASM build hosted in a worker
33
+ // thread of this process (engine/wasm.ts). Needs a key.
34
+ // Which one is used: a token/URL decides by itself (invite → ws, bridge →
35
+ // bridge). Without one the ENGINE POLICY applies (opts.engine / use_session's
36
+ // engine argument): 'auto' tries the worker first and falls back to the local
37
+ // WASM when the worker is not reachable; 'drogon' insists on the worker;
38
+ // 'wasm' goes local right away. The replies of both WASM paths have the same
39
+ // shape (messages + binary packages) and share one delivery path.
40
+ import WebSocket from 'ws';
41
+ import { randomUUID } from 'crypto';
42
+ import { connect as tcpConnect } from 'net';
43
+ import { startLocalEngine } from './engine/wasm.js';
44
+ const DEFAULT_URL = 'ws://0.0.0.0:9094/';
45
+ const REQUEST_TIMEOUT = 30_000;
46
+ const CONNECT_TIMEOUT = 5_000;
47
+ /** How often an 'auto' session that fell back to WASM looks for a worker again (ms). */
48
+ const WORKER_PROBE_MS = 3_000;
49
+ /** True when something accepts TCP connections at the worker URL's host:port. */
50
+ function workerListening(url) {
51
+ let host;
52
+ let port;
53
+ try {
54
+ const u = new URL(url);
55
+ host = u.hostname.replace(/^\[|\]$/g, '');
56
+ port = Number(u.port || (u.protocol === 'wss:' ? 443 : 80));
57
+ }
58
+ catch {
59
+ return Promise.resolve(false);
60
+ }
61
+ return new Promise(resolve => {
62
+ const sock = tcpConnect({ host, port });
63
+ const done = (ok) => {
64
+ sock.destroy();
65
+ resolve(ok);
66
+ };
67
+ sock.once('connect', () => done(true));
68
+ sock.once('error', () => done(false));
69
+ sock.setTimeout(500, () => done(false));
70
+ });
71
+ }
72
+ /**
73
+ * Around a graphic pull inside a suppressed script: kernel graphics only —
74
+ * the content the renderer contract has always been built on. Switched back
75
+ * right after the GetTree. (The suppression profile itself lives in
76
+ * @classcad/script's emission.ts and is applied by runScript.)
77
+ */
78
+ export const PULL_GRAPHIC_ON = { sendGraphic_Kernel: true };
79
+ /**
80
+ * This release does not hand out OFB (the native, parametric format): the
81
+ * commands that serialize to OFB — common.save and assembly.exportNode, whose
82
+ * default format IS OFB — are refused whether the data would be returned,
83
+ * written to a file or sent to a url. STEP/STL exports and loading OFB stay.
84
+ * Returns the refusal message, or null when the task may run.
85
+ */
86
+ export function ofbExportRefusal(task) {
87
+ for (const [command, args] of Object.entries(task)) {
88
+ if (command !== 'v1.common.save' && command !== 'v1.assembly.exportNode')
89
+ continue;
90
+ const params = (Array.isArray(args) ? args[0] : args);
91
+ const fromFile = typeof params?.file === 'string' && /\.[a-z0-9]+$/i.test(params.file) ? params.file.split('.').pop() : undefined;
92
+ const format = String(params?.format ?? fromFile ?? 'OFB').toUpperCase();
93
+ if (format === 'OFB')
94
+ return `${command.slice(3)}: OFB export is not available in this release of the ClassCAD MCP. Export STEP (format: "STP") or STL instead; checkpoint/restore still work for rollback.`;
95
+ }
96
+ return null;
97
+ }
98
+ export async function connect(url = DEFAULT_URL, opts = {}) {
99
+ const graphics = opts.graphics !== false;
100
+ const debug = opts.debug === true;
101
+ let outcomeUnknown = false;
102
+ const pending = new PendingRequests();
103
+ // The URL is used VERBATIM — it may carry a multi-client token/invite query
104
+ // (e.g. wss://host/?invite=…), in which case the server itself decides the
105
+ // session. currentUrl is mutable (reconnectUrl switches servers at runtime);
106
+ // baseUrl is the configured worker — session-id reconnects always return to it.
107
+ const baseUrl = url;
108
+ let generation = 0;
109
+ let currentUrl = url;
110
+ let lastGraphic = null;
111
+ let lastStructure = null;
112
+ // Cache bookkeeping: `version` bumps on every tracked request. treeVersion
113
+ // is the version whose structure the tree cache holds (fed by every Result
114
+ // that carries a structure snapshot - full emission - or by a pull);
115
+ // graphicVersion the one the graphic cache holds (fed by pulls only).
116
+ // Equal to `version` → current → no round trip.
117
+ let version = 0;
118
+ let treeVersion = -1;
119
+ let graphicVersion = -1;
120
+ // Last known sendGraphic_Kernel of the connection (from every config echo);
121
+ // null = never asked (engine default: on). pull() uses it to decide
122
+ // whether the kernel graphic must be switched on for the GetTree.
123
+ let knownKernel = null;
124
+ let ws = undefined;
125
+ // Tracks the *intended* session id. Starts as the value passed to connect()
126
+ // (typically null) and is updated by reconnect(). It only becomes the
127
+ // actual session id of the worker connection after openWs() succeeds.
128
+ let currentSessionId = opts.sessionId ?? null;
129
+ // Single-flight guard so concurrent tool calls share one open attempt.
130
+ let connectPromise = null;
131
+ // ── Bridge transport (in-app engines) ──
132
+ // Instead of a WebSocket to a worker, commands go to the app that announced
133
+ // `bridgeToken` on the bridge listener; the app runs them on its WASM engine
134
+ // and returns everything the engine emitted. The replies are normalized to
135
+ // the worker's frame shape and fed through handleFrame like WS frames.
136
+ let transport = 'ws';
137
+ let bridgeConn = null;
138
+ // ── Local WASM engine (engine/wasm.ts) ──
139
+ // Started on first use under the 'wasm' policy (or 'auto' with the worker
140
+ // unreachable); kept for the client's lifetime — a restart costs seconds
141
+ // and there is no other session to switch to. close() terminates it.
142
+ let localEngine = null;
143
+ let policy = opts.engine ?? 'auto';
144
+ // 'auto' landed on the local engine because no worker answered. The order is
145
+ // token > worker > WASM, so a worker that comes up later takes over — while
146
+ // the drawing is still empty; after that, switching would lose the model.
147
+ let autoFallback = false;
148
+ let lastWorkerProbe = 0;
149
+ let workerWaitingLogged = false;
150
+ let probing = false;
151
+ const wasmAvailable = () => !!opts.wasm?.key;
152
+ const log = opts.log ?? (() => { });
153
+ let bridgeToken = null;
154
+ let bridgeUnsubClose = null;
155
+ let bridgePeerId = null;
156
+ // The invite token of a server session joined via reconnectUrl (?invite=…).
157
+ let inviteToken = null;
158
+ // In-app engines emit graphic as binary packages per command (no bundled
159
+ // graphic on GetTree). Accumulate containers by id so a pull can hand the
160
+ // renderer the complete picture; the renderer drops consumed bodies itself.
161
+ const bridgeContainers = new Map();
162
+ function send(obj) {
163
+ if (transport === 'bridge' || transport === 'wasm') {
164
+ sendViaEngine(obj);
165
+ return;
166
+ }
167
+ if (!ws)
168
+ throw new Error('WebSocket not open');
169
+ ws.send(JSON.stringify(obj));
170
+ }
171
+ /** Bridge and local WASM: one request → everything the engine emitted for it. */
172
+ function sendViaEngine(req) {
173
+ const txId = String(req.transactionID);
174
+ let run;
175
+ if (transport === 'wasm') {
176
+ if (!localEngine) {
177
+ const entry = pending.get(txId);
178
+ pending.delete(txId);
179
+ entry?.reject(new Error('local WASM engine is not running'));
180
+ return;
181
+ }
182
+ run = localEngine.execute(req);
183
+ }
184
+ else {
185
+ const conn = bridgeConn;
186
+ if (!conn) {
187
+ const entry = pending.get(txId);
188
+ pending.delete(txId);
189
+ entry?.reject(new Error('bridge not attached — call use_session with the app\'s share link'));
190
+ return;
191
+ }
192
+ run = conn.request('engine.execute', req);
193
+ }
194
+ run.then(res => deliverEngineResult(req, res), err => {
195
+ const entry = pending.get(txId);
196
+ if (entry) {
197
+ pending.delete(txId);
198
+ entry.reject(err instanceof Error ? err : new Error(String(err)));
199
+ }
200
+ });
201
+ }
202
+ /** Turns an engine reply (bridge or local WASM) into worker-shaped Result frames and delivers them. */
203
+ function deliverEngineResult(req, res) {
204
+ const txId = String(req.transactionID);
205
+ const fail = (why) => {
206
+ const entry = pending.get(txId);
207
+ if (entry) {
208
+ pending.delete(txId);
209
+ entry.reject(new Error(why));
210
+ }
211
+ };
212
+ if (res.decodeErrors?.length)
213
+ log(`${String(req.command)}: dropped undecodable engine output (${res.decodeErrors.join('; ')})`);
214
+ for (const pkg of res.binaryMessages ?? []) {
215
+ for (const c of pkg?.containers ?? []) {
216
+ if (c && c.id != null)
217
+ bridgeContainers.set(String(c.id), c);
218
+ }
219
+ }
220
+ // Errors the engine reported on the side (ErrorMessage frames, errorState 2).
221
+ const sideErrors = (res.messages ?? [])
222
+ .filter(m => m?.command === 'ErrorMessage' && Number(m.attributes?.errorState) >= 2)
223
+ .map(m => ({ level: 51, levelStr: 'ERROR', code: m.attributes?.errorCode ?? 0, message: String(m.attributes?.errorMessage ?? 'engine error') }));
224
+ let delivered = false;
225
+ for (const m of res.messages ?? []) {
226
+ if (m.command !== 'Result')
227
+ continue;
228
+ const frame = {
229
+ ...m,
230
+ _from_: m._from_ ?? m.from ?? req.command,
231
+ _transactionID_: m._transactionID_ ?? m.transactionID ?? req.transactionID,
232
+ };
233
+ if (frame._from_ === 'Execute') {
234
+ // Every Execute Result carries a value (null included) or a level. One
235
+ // with neither is not an answer — never let it pass as a success.
236
+ if (!('result' in frame) && frame.maxLevel == null) {
237
+ fail(`engine returned an empty Result for ${describeRequest(req)}`);
238
+ return;
239
+ }
240
+ // An error reported only as ErrorMessage must not turn into an empty success.
241
+ const n = normalizeResult(frame);
242
+ if (sideErrors.length && (n.maxLevel ?? 0) < 51 && n.result == null) {
243
+ frame.maxLevel = 51;
244
+ frame.messages = [...(Array.isArray(frame.messages) ? frame.messages : []), ...sideErrors];
245
+ }
246
+ }
247
+ if (frame._from_ === 'GetTree' || frame._from_ === 'Sync') {
248
+ // Legacy engines put the structure into `result`; the WS worker puts it into `structure`.
249
+ if (!frame.structure && frame.result && typeof frame.result === 'object' && 'tree' in frame.result) {
250
+ frame.structure = frame.result;
251
+ delete frame.result;
252
+ }
253
+ // A pull delivers the COMPLETE graphic: the accumulated containers.
254
+ frame.graphic = { containers: [...bridgeContainers.values()] };
255
+ }
256
+ delivered = true;
257
+ handleFrame(Buffer.from(JSON.stringify(frame)), false);
258
+ }
259
+ if (!delivered) {
260
+ const detail = [...sideErrors.map(e => e.message), ...(res.decodeErrors ?? [])];
261
+ fail(`engine returned no Result for ${describeRequest(req)}${detail.length ? ` (${detail.join('; ')})` : ''}`);
262
+ }
263
+ }
264
+ function describeRequest(req) {
265
+ const task = Array.isArray(req.task) ? req.task[0] : undefined;
266
+ const method = task ? Object.keys(task)[0] : undefined;
267
+ return method ? `${String(req.command)} ${method}` : String(req.command);
268
+ }
269
+ function detachBridge() {
270
+ if (bridgeUnsubClose)
271
+ bridgeUnsubClose();
272
+ bridgeUnsubClose = null;
273
+ const conn = bridgeConn;
274
+ bridgeConn = null;
275
+ if (conn && bridgePeerId) {
276
+ conn.request('session.detached', { peerId: bridgePeerId }).catch(() => { });
277
+ }
278
+ bridgePeerId = null;
279
+ bridgeToken = null;
280
+ bridgeContainers.clear();
281
+ }
282
+ function handleFrame(data, isBinary) {
283
+ if (isBinary)
284
+ return;
285
+ let frame;
286
+ try {
287
+ frame = JSON.parse(data.toString());
288
+ }
289
+ catch {
290
+ return;
291
+ }
292
+ const txId = frame._transactionID_;
293
+ if (!txId)
294
+ return;
295
+ const entry = pending.get(txId);
296
+ if (!entry)
297
+ return;
298
+ if (frame.command !== 'Result')
299
+ return;
300
+ pending.delete(txId);
301
+ // A pull (GetTree/Sync) delivers the COMPLETE graphic — replace, never
302
+ // merge (a merge would keep containers of deleted bodies alive). Graphic
303
+ // on other Results covers only the changed ids and is ignored here.
304
+ const complete = completeGraphic(frame);
305
+ if (complete.present) {
306
+ lastGraphic = complete.graphic;
307
+ graphicVersion = Math.max(graphicVersion, entry.at);
308
+ }
309
+ // Every structure snapshot is complete (the engine sends the whole tree).
310
+ if (frame.structure && typeof frame.structure === 'object' && !Array.isArray(frame.structure)) {
311
+ lastStructure = frame.structure;
312
+ treeVersion = Math.max(treeVersion, entry.at);
313
+ }
314
+ entry.resolve(normalizeResult(frame));
315
+ }
316
+ async function request(command, extra = {}, o = {}) {
317
+ if (outcomeUnknown)
318
+ throw new Error('Session outcome unknown after request timeout; reconnect before further work');
319
+ await ensureOpen();
320
+ if (o.track !== false)
321
+ version++;
322
+ const transactionID = randomUUID();
323
+ return new Promise((resolve, reject) => {
324
+ pending.register(transactionID, { resolve: resolve, reject, at: version }, { command, timeoutMs: debug ? undefined : opts.requestTimeoutMs ?? REQUEST_TIMEOUT, onTimeout: () => {
325
+ // The local engine runs one command at a time on its own thread: one
326
+ // that does not answer is stuck, and everything queued behind it with
327
+ // it. Retire it — the next command starts a new one (empty drawing).
328
+ if (transport === 'wasm' && localEngine && !localEngine.closed) {
329
+ log(`local WASM engine did not answer ${command} within the request timeout — retired; the next command starts a new engine with an empty drawing`);
330
+ localEngine.close(`did not answer ${command} within the request timeout`);
331
+ }
332
+ else
333
+ outcomeUnknown = true;
334
+ } });
335
+ // No emission flags travel with a request — the engine keeps them per
336
+ // connection (see SUPPRESS_EMISSION / setEmissionConfig in openWs).
337
+ try {
338
+ send({ command, commandVersion: 'v1', transactionID, ...extra });
339
+ }
340
+ catch (error) {
341
+ pending.delete(transactionID);
342
+ reject(error);
343
+ }
344
+ });
345
+ }
346
+ // Open the WS lazily on first use. Single-flight: concurrent callers share
347
+ // the in-flight open. After a successful open, ws stays defined; close()
348
+ // and reconnect() reset it as needed.
349
+ async function ensureOpen() {
350
+ if (transport === 'bridge') {
351
+ if (bridgeConn)
352
+ return;
353
+ throw new Error('The app behind the share link is gone (bridge closed). Either reopen the share in the app and call use_session with the new link, ' +
354
+ 'or leave the app: use_session with engine "wasm" runs on the MCP\'s own local engine, engine "drogon" on the ClassCAD worker, "auto" picks.');
355
+ }
356
+ if (transport === 'wasm' && localEngine?.closed) {
357
+ if (connectPromise)
358
+ return connectPromise;
359
+ connectPromise = restartLocalEngine(localEngine.deathReason ?? 'closed').finally(() => {
360
+ connectPromise = null;
361
+ });
362
+ return connectPromise;
363
+ }
364
+ if (transport === 'wasm' && localEngine) {
365
+ // Not single-flight on purpose: the probe itself reads the tree through
366
+ // request() → ensureOpen(), which must pass straight through (probing).
367
+ if (autoFallback && policy === 'auto' && !probing) {
368
+ probing = true;
369
+ try {
370
+ await preferWorker();
371
+ }
372
+ finally {
373
+ probing = false;
374
+ }
375
+ }
376
+ return;
377
+ }
378
+ if (transport === 'ws' && ws && ws.readyState === WebSocket.OPEN)
379
+ return;
380
+ if (connectPromise)
381
+ return connectPromise;
382
+ connectPromise = openByPolicy(policy, currentSessionId).finally(() => {
383
+ connectPromise = null;
384
+ });
385
+ return connectPromise;
386
+ }
387
+ /**
388
+ * Opens the engine the policy asks for. 'auto' = the worker, and when it
389
+ * is not reachable (refused/timeout — not a failure AFTER connecting) the
390
+ * local WASM engine, if a key is configured. Explicit session ids only
391
+ * make sense on a worker: with one given, 'auto' does not fall back.
392
+ */
393
+ async function openByPolicy(how, sessionId) {
394
+ autoFallback = false;
395
+ if (how === 'wasm') {
396
+ if (!wasmAvailable())
397
+ throw new Error('No local WASM engine: set CLASSCAD_WASM_KEY (a ClassCAD key from classcad.ch/user) to let the MCP host the engine itself, or use engine "drogon" with a running ClassCAD worker.');
398
+ await openWasm();
399
+ return;
400
+ }
401
+ try {
402
+ await openWs(sessionId);
403
+ }
404
+ catch (err) {
405
+ const unreachable = err instanceof Error && err.connectFailure === true;
406
+ if (how === 'auto' && unreachable && !sessionId && wasmAvailable()) {
407
+ log(`no ClassCAD worker at ${currentUrl} (${err.message || err.code || 'refused'}) — using the local WASM engine until one is up`);
408
+ await openWasm();
409
+ autoFallback = true;
410
+ lastWorkerProbe = Date.now();
411
+ return;
412
+ }
413
+ if (unreachable && how === 'auto' && !wasmAvailable()) {
414
+ throw new Error(`No ClassCAD worker reachable at ${currentUrl} (${err.message}). Start one (classcad-cli worker) or set CLASSCAD_WS_URL — ` +
415
+ 'or set CLASSCAD_WASM_KEY so the MCP can host the engine itself (local WASM).');
416
+ }
417
+ throw err;
418
+ }
419
+ }
420
+ /**
421
+ * The 'auto' fallback, re-checked before commands (throttled): a worker that
422
+ * is listening now takes over an EMPTY local drawing. MCP tool calls run
423
+ * one at a time (queue.ts), so nothing else is mid-command on the engine.
424
+ */
425
+ async function preferWorker() {
426
+ const now = Date.now();
427
+ if (now - lastWorkerProbe < WORKER_PROBE_MS)
428
+ return;
429
+ lastWorkerProbe = now;
430
+ if (!(await workerListening(currentUrl)))
431
+ return;
432
+ let empty = false;
433
+ try {
434
+ const tree = await getTree({ refresh: true });
435
+ empty = Object.values(tree).every((n) => n?.class === 'AllObjects');
436
+ }
437
+ catch {
438
+ return;
439
+ }
440
+ if (!empty) {
441
+ if (!workerWaitingLogged) {
442
+ log(`ClassCAD worker at ${currentUrl} is up, but this session's model lives on the local WASM engine — staying there (use_session engine "drogon" starts fresh on the worker)`);
443
+ workerWaitingLogged = true;
444
+ }
445
+ autoFallback = false;
446
+ return;
447
+ }
448
+ try {
449
+ await openWs(null);
450
+ autoFallback = false;
451
+ log(`ClassCAD worker at ${currentUrl} is up — moved this (still empty) session from the local WASM engine to the worker`);
452
+ }
453
+ catch (err) {
454
+ log(`worker at ${currentUrl} listens but did not open (${err.message}) — staying on the local WASM engine`);
455
+ await openWasm();
456
+ }
457
+ }
458
+ /** Switches this client to the MCP's own WASM engine (started on first use). */
459
+ async function openWasm() {
460
+ if (ws && ws.readyState <= WebSocket.OPEN) {
461
+ try {
462
+ ws.close();
463
+ }
464
+ catch { }
465
+ ws = undefined;
466
+ }
467
+ if (transport === 'bridge')
468
+ detachBridge();
469
+ resetCaches();
470
+ transport = 'wasm';
471
+ currentSessionId = null;
472
+ inviteToken = null;
473
+ // A reused engine (use_session, the 'auto' fallback) must still answer;
474
+ // a dead or wedged one is replaced by a new engine (empty drawing).
475
+ if (localEngine && (localEngine.closed || !(await localEngine.ping()))) {
476
+ const why = localEngine.deathReason ?? 'did not answer a health check';
477
+ log(`local WASM engine ${why} — starting a new one (the drawing is lost)`);
478
+ localEngine.close(why);
479
+ localEngine = null;
480
+ }
481
+ if (!localEngine) {
482
+ localEngine = await startLocalEngine({ ...opts.wasm, log });
483
+ // A new engine has default database settings. Checkpoints (save
484
+ // payloads held here) stay valid: restore loads them into this one.
485
+ ensuredGeneration = -1;
486
+ await bootstrapSession();
487
+ }
488
+ }
489
+ /** Replaces a retired local engine (crash, trap, timeout) with a new one. */
490
+ async function restartLocalEngine(why) {
491
+ log(`local WASM engine is gone (${why}) — starting a new one with an empty drawing`);
492
+ localEngine?.close(why);
493
+ localEngine = null;
494
+ await openWasm();
495
+ }
496
+ function execute(task, opts = {}) {
497
+ const refused = opts.internalOfb ? null : ofbExportRefusal(task);
498
+ if (refused)
499
+ return Promise.reject(new Error(refused));
500
+ return request('Execute', { task: [task], options: { undoable: false } });
501
+ }
502
+ function close() {
503
+ if (transport === 'bridge')
504
+ detachBridge();
505
+ if (ws && ws.readyState <= WebSocket.OPEN)
506
+ ws.close();
507
+ if (localEngine) {
508
+ localEngine.close();
509
+ localEngine = null;
510
+ }
511
+ }
512
+ function getLastGraphic() {
513
+ return lastGraphic;
514
+ }
515
+ function getStructure() {
516
+ return lastStructure;
517
+ }
518
+ // Per-connection emission config (untracked: it changes what the engine
519
+ // SENDS, not the model). The reply carries the effective flags in `result`;
520
+ // an engine without the commands answers without `result`. Nothing is set
521
+ // on connect - run_script scopes the suppression to a script.
522
+ function rememberKernel(cfg) {
523
+ if (cfg && typeof cfg.sendGraphic_Kernel === 'boolean')
524
+ knownKernel = cfg.sendGraphic_Kernel;
525
+ return cfg;
526
+ }
527
+ async function getEmissionConfig() {
528
+ return rememberKernel((await request('GetEmissionConfig', {}, { track: false })).result);
529
+ }
530
+ async function setEmissionConfig(partial) {
531
+ return rememberKernel((await request('SetEmissionConfig', { config: partial }, { track: false })).result);
532
+ }
533
+ /**
534
+ * Fill BOTH caches with one GetTree. Not tracked — it is not a mutation.
535
+ * GetTree always returns the structure; inside a suppressed script the
536
+ * kernel graphic is switched on around it and back to what it was.
537
+ */
538
+ async function pull() {
539
+ const at = version;
540
+ const toggle = graphics && knownKernel === false;
541
+ const read = async () => {
542
+ await request('GetTree', {}, { track: false });
543
+ graphicVersion = Math.max(graphicVersion, at);
544
+ };
545
+ if (toggle)
546
+ await withEmissionOverride({ getEmissionConfig, setEmissionConfig }, PULL_GRAPHIC_ON, read);
547
+ else
548
+ await read();
549
+ }
550
+ const treeCurrent = () => treeVersion === version && lastStructure !== null;
551
+ // No null check on lastGraphic: a pull that delivered no graphic (graphics:false, empty model) is still current.
552
+ const graphicCurrent = () => graphicVersion === version;
553
+ async function getTree(o) {
554
+ if (o?.refresh || !treeCurrent())
555
+ await pull();
556
+ return (lastStructure?.tree ?? {});
557
+ }
558
+ // The engine omits brep EDGE data from graphic payloads until the graphic
559
+ // database settings are enabled — ensure once per (re)connect generation.
560
+ // Untracked: it changes engine settings, not the model.
561
+ let ensuredGeneration = -1;
562
+ async function ensureGraphics() {
563
+ if (ensuredGeneration === generation)
564
+ return;
565
+ ensuredGeneration = generation;
566
+ try {
567
+ await request('Execute', {
568
+ task: [
569
+ {
570
+ 'v1.common.setDatabaseSettings': [
571
+ { isGraphicEnabled: true, isCCGraphicEnabled: true, isSketchGraphicEnabled: true, doCurveTessellation: true },
572
+ ],
573
+ },
574
+ ],
575
+ options: { undoable: false },
576
+ }, { track: false });
577
+ }
578
+ catch {
579
+ /* older servers — proceed without edges */
580
+ }
581
+ }
582
+ async function getGraphic(o) {
583
+ await ensureGraphics();
584
+ // recalc is OPT-IN: it regenerates the whole model and DESTROYS
585
+ // entity-injection bodies. The pull alone reflects the current model.
586
+ if (o?.recalc === true) {
587
+ const result = await execute({ 'v1.common.recalc': [{}] });
588
+ if ((result.maxLevel ?? 0) >= 51)
589
+ throw new Error('Model regeneration failed: ' + JSON.stringify(result.messages));
590
+ }
591
+ if (!graphicCurrent())
592
+ await pull();
593
+ return lastGraphic;
594
+ }
595
+ async function refreshTree() {
596
+ try {
597
+ await pull();
598
+ }
599
+ catch {
600
+ /* non-fatal — caller sees lastStructure unchanged */
601
+ }
602
+ return lastStructure;
603
+ }
604
+ // After (re)connecting, populate the caches and make sure a current
605
+ // instance is set. Without this:
606
+ // 1. tree/find return null/empty until the first pull.
607
+ // 2. snapshot renders only the BaseWCSys axes because the renderer's
608
+ // pull has no current product to walk.
609
+ // Both effects bite hardest when use_session attaches to a session another
610
+ // client (Buerligons, etc.) already has a model loaded in.
611
+ async function bootstrapSession() {
612
+ try {
613
+ await pull();
614
+ }
615
+ catch {
616
+ return;
617
+ }
618
+ const s = lastStructure;
619
+ if (!s)
620
+ return;
621
+ const cur = Number(s.currentInstance ?? 0);
622
+ if (cur)
623
+ return;
624
+ let rootId = null;
625
+ for (const node of Object.values(s.tree ?? {})) {
626
+ if (node && node.class === 'CC_AssemblyRoot') {
627
+ rootId = node.id;
628
+ break;
629
+ }
630
+ }
631
+ if (rootId == null)
632
+ return;
633
+ try {
634
+ await request('Execute', {
635
+ task: [{ 'v1.assembly.setCurrentInstance': [{ id: rootId }] }],
636
+ options: { undoable: false },
637
+ });
638
+ await pull();
639
+ }
640
+ catch {
641
+ /* non-fatal */
642
+ }
643
+ }
644
+ // Open (or replace) the underlying WebSocket. Used both for the initial
645
+ // connect() call and for reconnect(sessionId). Resets cached structure /
646
+ // graphic state and rejects any in-flight requests on the old socket.
647
+ function resetCaches() {
648
+ outcomeUnknown = false;
649
+ for (const [, entry] of pending) {
650
+ try {
651
+ entry.reject(new Error('WebSocket reconnecting'));
652
+ }
653
+ catch { }
654
+ }
655
+ pending.clear();
656
+ lastGraphic = null;
657
+ lastStructure = null;
658
+ treeVersion = -1;
659
+ graphicVersion = -1;
660
+ knownKernel = null;
661
+ }
662
+ async function openBridge(token) {
663
+ const registry = opts.bridge?.() ?? null;
664
+ if (!registry)
665
+ throw new Error('The bridge listener is not running in this MCP (CLASSCAD_BRIDGE_LISTEN failed to bind, or the MCP runs in-process without a daemon).');
666
+ if (ws && ws.readyState <= WebSocket.OPEN) {
667
+ try {
668
+ ws.close();
669
+ }
670
+ catch { }
671
+ ws = undefined;
672
+ }
673
+ detachBridge();
674
+ resetCaches();
675
+ transport = 'bridge';
676
+ bridgeToken = token;
677
+ // Apps reconnect with backoff (buerli connectBridge: 500 ms … 5 s) after a
678
+ // daemon restart; wait longer than the largest backoff step.
679
+ const conn = await registry.waitFor(token, 8_000);
680
+ if (!conn) {
681
+ transport = 'ws';
682
+ bridgeToken = null;
683
+ throw new Error(`No app has announced share token "${token}" on ${registry.url}. ` +
684
+ 'Create the share in the app first (it connects to this MCP when the token is minted), then call use_session again.');
685
+ }
686
+ if (conn.kind !== 'bridge') {
687
+ transport = 'ws';
688
+ bridgeToken = null;
689
+ throw new Error(`Token "${token}" belongs to a server session (invite), not an in-app engine — pass it as ?invite= instead.`);
690
+ }
691
+ bridgeConn = conn;
692
+ bridgePeerId = randomUUID();
693
+ bridgeUnsubClose = conn.onClose(() => {
694
+ if (bridgeConn !== conn)
695
+ return;
696
+ bridgeConn = null;
697
+ for (const [, entry] of pending) {
698
+ try {
699
+ entry.reject(new Error('bridge closed — the app revoked the share or went away'));
700
+ }
701
+ catch { }
702
+ }
703
+ pending.clear();
704
+ });
705
+ try {
706
+ await conn.request('session.attached', { peerId: bridgePeerId, role: 'edit', client: 'classcad-mcp' });
707
+ }
708
+ catch {
709
+ /* older apps without the session.* handlers still relay engine commands */
710
+ }
711
+ await bootstrapSession();
712
+ }
713
+ async function openWs(sessionId) {
714
+ if (transport === 'bridge')
715
+ detachBridge();
716
+ // Leaving the local engine for a worker: the engine stays warm in case
717
+ // the policy brings us back; close() ends it with the client.
718
+ transport = 'ws';
719
+ if (ws && ws.readyState <= WebSocket.OPEN) {
720
+ try {
721
+ ws.close();
722
+ }
723
+ catch { }
724
+ }
725
+ for (const [, entry] of pending) {
726
+ try {
727
+ entry.reject(new Error('WebSocket reconnecting'));
728
+ }
729
+ catch { }
730
+ }
731
+ pending.clear();
732
+ lastGraphic = null;
733
+ lastStructure = null;
734
+ treeVersion = -1;
735
+ graphicVersion = -1;
736
+ knownKernel = null;
737
+ const wsOpts = sessionId ? { headers: { 'ClassCAD-Session-Id': sessionId } } : {};
738
+ const sock = new WebSocket(currentUrl, wsOpts);
739
+ ws = sock;
740
+ sock.on('error', (err) => log(`ClassCAD WebSocket error: ${err.message}`));
741
+ // Failures HERE mean "no worker there" (refused, unreachable, timeout) —
742
+ // marked so the policy can fall back; failures after the open are not.
743
+ await new Promise((resolve, reject) => {
744
+ let timer;
745
+ const cleanup = () => {
746
+ if (timer)
747
+ clearTimeout(timer);
748
+ sock.off('open', opened);
749
+ sock.off('error', fail);
750
+ };
751
+ const opened = () => {
752
+ cleanup();
753
+ resolve();
754
+ };
755
+ const fail = (err) => {
756
+ cleanup();
757
+ err.connectFailure = true;
758
+ try {
759
+ sock.close();
760
+ }
761
+ catch { }
762
+ reject(err);
763
+ };
764
+ sock.once('open', opened);
765
+ sock.once('error', fail);
766
+ if (!debug)
767
+ timer = setTimeout(() => fail(new Error('Connection timeout')), opts.connectTimeoutMs ?? CONNECT_TIMEOUT);
768
+ });
769
+ sock.on('message', (d, b) => handleFrame(d, b));
770
+ sock.on('close', () => {
771
+ if (ws !== sock)
772
+ return;
773
+ for (const entry of pending.values())
774
+ entry.reject(new Error('Worker disconnected; in-flight mutation outcome unknown'));
775
+ pending.clear();
776
+ });
777
+ // No emission config is set here on purpose (see the header): the
778
+ // connection keeps the engine's defaults so a shared session's app sees
779
+ // everything this client emits. run_script suppresses per script.
780
+ await bootstrapSession();
781
+ }
782
+ /**
783
+ * use_session without a URL: a named worker session, or "whatever the
784
+ * policy says" (engine argument; undefined keeps the client's policy).
785
+ */
786
+ async function reconnect(sessionId, engine) {
787
+ generation++;
788
+ inviteToken = null;
789
+ // Always perform an open so the caller gets back a connected, usable
790
+ // socket. Cancels any in-flight ensureOpen() so it doesn't race with us.
791
+ connectPromise = null;
792
+ currentUrl = baseUrl;
793
+ currentSessionId = sessionId;
794
+ if (engine)
795
+ policy = engine;
796
+ await openByPolicy(sessionId ? 'drogon' : policy, sessionId);
797
+ }
798
+ async function reconnectUrl(newUrl) {
799
+ generation++;
800
+ // Token/invite URLs address the session themselves — no session header.
801
+ connectPromise = null;
802
+ currentUrl = newUrl;
803
+ currentSessionId = null;
804
+ try {
805
+ inviteToken = new URL(newUrl).searchParams.get('invite');
806
+ }
807
+ catch {
808
+ inviteToken = null;
809
+ }
810
+ await openWs(null);
811
+ }
812
+ async function reconnectBridge(token) {
813
+ generation++;
814
+ connectPromise = null;
815
+ currentSessionId = null;
816
+ inviteToken = null;
817
+ await openBridge(token);
818
+ }
819
+ // No eager open here. The WS is opened on first request/execute/pull call
820
+ // (via ensureOpen) or immediately by reconnect(). This keeps the MCP
821
+ // passive at startup so it doesn't create stray ephemeral worker sessions.
822
+ return {
823
+ request,
824
+ execute,
825
+ close,
826
+ getStructure,
827
+ getLastGraphic,
828
+ getTree,
829
+ getGraphic,
830
+ pull,
831
+ getEmissionConfig,
832
+ setEmissionConfig,
833
+ refreshTree,
834
+ reconnect,
835
+ reconnectUrl,
836
+ reconnectBridge,
837
+ get ws() {
838
+ return ws;
839
+ },
840
+ get transport() {
841
+ return transport;
842
+ },
843
+ get engine() {
844
+ return policy;
845
+ },
846
+ get wasmAvailable() {
847
+ return wasmAvailable();
848
+ },
849
+ get localEngine() {
850
+ return localEngine;
851
+ },
852
+ get shareToken() {
853
+ return bridgeToken ?? inviteToken;
854
+ },
855
+ get connected() {
856
+ if (transport === 'bridge')
857
+ return bridgeConn !== null;
858
+ if (transport === 'wasm')
859
+ return localEngine !== null;
860
+ return !!ws && ws.readyState === WebSocket.OPEN;
861
+ },
862
+ get sessionId() {
863
+ return currentSessionId;
864
+ },
865
+ get url() {
866
+ return currentUrl;
867
+ },
868
+ get baseUrl() {
869
+ return baseUrl;
870
+ },
871
+ get generation() {
872
+ return generation;
873
+ },
874
+ get version() {
875
+ return version;
876
+ },
877
+ };
878
+ }
879
+ //# sourceMappingURL=client.js.map