@vscxml/mcp 0.1.3 → 0.1.5

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.
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@ import { z } from 'zod';
3
3
  import { GeneratorBridge } from './bridges/generator-bridge.js';
4
4
  import { SimulatorBridge } from './bridges/simulator-bridge.js';
5
5
  import { EditorBridge } from './bridges/editor-bridge.js';
6
- import { ProcessManager } from './process-manager.js';
6
+ import { readDiscoveryFiles } from './process-manager.js';
7
7
  // Semantic color presets for editor_highlight
8
8
  const HIGHLIGHT_PRESETS = {
9
9
  error: '#ef4444',
@@ -21,17 +21,96 @@ export function createServer(config = {}) {
21
21
  const generator = new GeneratorBridge(config.generatorUrl);
22
22
  const simulator = new SimulatorBridge(config.simulatorUrl);
23
23
  const editor = new EditorBridge(config.editorUrl);
24
- const processManager = new ProcessManager();
24
+ // ═══════════════════════════════════════════════
25
+ // Availability Guards
26
+ // ═══════════════════════════════════════════════
27
+ const GENERATOR_TOOLS = ['scxml_validate', 'scxml_inspect', 'scxml_create', 'scxml_generate', 'scxml_generate_project', 'scxml_list_targets'];
28
+ const SIMULATOR_TOOLS = ['scxml_sim_start', 'scxml_sim_send', 'scxml_sim_scenario', 'scxml_sim_get_state', 'scxml_sim_reset', 'scxml_sim_set_variable', 'scxml_trace_list', 'scxml_trace_embed', 'scxml_trace_get', 'scxml_trace_play', 'scxml_trace_step', 'scxml_trace_delete'];
29
+ const EDITOR_TOOLS = ['editor_push_scxml', 'editor_get_scxml', 'editor_highlight', 'editor_add_note', 'editor_remove_notes', 'editor_navigate', 'editor_show_notification', 'editor_save_file', 'editor_load_file', 'editor_screenshot', 'editor_export_svg', 'editor_export_png', 'editor_export_player_html', 'editor_get_selection', 'editor_get_viewport', 'editor_connect_simulator'];
30
+ function errorResult(obj) {
31
+ return { content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }] };
32
+ }
33
+ async function checkGenerator() {
34
+ if (await generator.isAvailable())
35
+ return null;
36
+ return errorResult({
37
+ error: 'Generator is not running',
38
+ url: generator.getUrl(),
39
+ startHint: 'Start the generator server: vscxml-generator-cli serve --port 48620 --cors',
40
+ tools: GENERATOR_TOOLS,
41
+ });
42
+ }
43
+ async function checkSimulator() {
44
+ if (await simulator.isAvailable())
45
+ return null;
46
+ return errorResult({
47
+ error: 'Simulator is not running',
48
+ url: simulator.getUrl(),
49
+ startHint: 'Start the simulator or open it from VSCXML-Editor',
50
+ tools: SIMULATOR_TOOLS,
51
+ });
52
+ }
53
+ async function checkEditor() {
54
+ if (await editor.isAvailable())
55
+ return null;
56
+ return errorResult({
57
+ error: 'VSCXML-Editor is not running',
58
+ url: editor.getUrl(),
59
+ startHint: 'Start the VSCXML-Editor desktop application',
60
+ tools: EDITOR_TOOLS,
61
+ });
62
+ }
63
+ async function requireSession() {
64
+ const unavailable = await checkSimulator();
65
+ if (unavailable)
66
+ return unavailable;
67
+ if (!simulator.getSessionId()) {
68
+ return errorResult({
69
+ error: 'No active simulation session',
70
+ action: 'Call scxml_sim_start with your SCXML content first.',
71
+ });
72
+ }
73
+ return null;
74
+ }
75
+ function buildSummary(gen, sim, ed) {
76
+ const up = [gen && 'generator', sim && 'simulator', ed && 'editor'].filter(Boolean);
77
+ const down = [!gen && 'generator', !sim && 'simulator', !ed && 'editor'].filter(Boolean);
78
+ const parts = [];
79
+ if (up.length === 3) {
80
+ parts.push('All backends connected.');
81
+ }
82
+ else {
83
+ if (up.length > 0)
84
+ parts.push(`Connected: ${up.join(', ')}.`);
85
+ if (down.length > 0)
86
+ parts.push(`Not connected: ${down.join(', ')}.`);
87
+ }
88
+ if (!gen)
89
+ parts.push('Start the generator to use design and code-generation tools.');
90
+ if (!sim)
91
+ parts.push('Start the simulator to use simulation and trace tools.');
92
+ if (!ed)
93
+ parts.push('Start VSCXML-Editor to use visual editing and export tools.');
94
+ if (ed && sim)
95
+ parts.push('Tip: Use editor_connect_simulator to link the editor to the simulator for live state highlights.');
96
+ return parts.join(' ');
97
+ }
25
98
  // ═══════════════════════════════════════════════
26
99
  // Design Tools
27
100
  // ═══════════════════════════════════════════════
28
101
  server.tool('scxml_validate', 'Validate SCXML against the W3C spec. Returns errors/warnings, state count, transition count, and datamodel type.', { scxml: z.string().describe('SCXML XML content to validate') }, async ({ scxml }) => {
102
+ const unavailable = await checkGenerator();
103
+ if (unavailable)
104
+ return unavailable;
29
105
  const result = await generator.validate(scxml);
30
106
  return {
31
107
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
32
108
  };
33
109
  });
34
110
  server.tool('scxml_inspect', 'Inspect an SCXML file and return its full structured model: states, transitions, events, guards, actions, data variables, and hierarchy. Use this to reason about a state machine without seeing a diagram.', { scxml: z.string().describe('SCXML XML content to inspect') }, async ({ scxml }) => {
111
+ const unavailable = await checkGenerator();
112
+ if (unavailable)
113
+ return unavailable;
35
114
  const result = await generator.inspect(scxml);
36
115
  return {
37
116
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -68,6 +147,9 @@ export function createServer(config = {}) {
68
147
  type: z.string().optional(),
69
148
  })).optional().default([]).describe('Top-level data variables'),
70
149
  }, async ({ name, datamodel, binding, states, transitions, data }) => {
150
+ const unavailable = await checkGenerator();
151
+ if (unavailable)
152
+ return unavailable;
71
153
  const result = await generator.create({
72
154
  name, datamodel, binding, states, transitions, data,
73
155
  });
@@ -86,6 +168,9 @@ export function createServer(config = {}) {
86
168
  plcPlatform: z.string().optional(),
87
169
  }).optional().describe('Target-specific options'),
88
170
  }, async ({ scxml, target, options }) => {
171
+ const unavailable = await checkGenerator();
172
+ if (unavailable)
173
+ return unavailable;
89
174
  const result = await generator.generateProject(scxml, target, options);
90
175
  // Build helpful metadata
91
176
  const buildCommands = {
@@ -114,9 +199,9 @@ export function createServer(config = {}) {
114
199
  // Simulation Tools
115
200
  // ═══════════════════════════════════════════════
116
201
  server.tool('scxml_sim_start', 'Load SCXML into the simulator and start a session. Returns the initial active states, variables, and enabled events.', { scxml: z.string().describe('SCXML XML content to simulate') }, async ({ scxml }) => {
117
- if (!simulator.isConnected()) {
118
- await simulator.connect();
119
- }
202
+ const unavailable = await checkSimulator();
203
+ if (unavailable)
204
+ return unavailable;
120
205
  await simulator.loadScxml(scxml);
121
206
  const state = await simulator.start();
122
207
  return {
@@ -127,9 +212,9 @@ export function createServer(config = {}) {
127
212
  event: z.string().describe('Event name to send'),
128
213
  data: z.unknown().optional().describe('Optional event data'),
129
214
  }, async ({ event, data }) => {
130
- if (!simulator.isConnected()) {
131
- throw new Error('No active simulation session. Use scxml_sim_start first.');
132
- }
215
+ const unavailable = await requireSession();
216
+ if (unavailable)
217
+ return unavailable;
133
218
  const result = await simulator.sendEvent(event, data);
134
219
  return {
135
220
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -146,9 +231,9 @@ export function createServer(config = {}) {
146
231
  ])).describe('Events to send in sequence'),
147
232
  stopOnError: z.boolean().optional().default(true).describe('Stop on first error (default: true)'),
148
233
  }, async ({ scxml, events, stopOnError }) => {
149
- if (!simulator.isConnected()) {
150
- await simulator.connect();
151
- }
234
+ const unavailable = await checkSimulator();
235
+ if (unavailable)
236
+ return unavailable;
152
237
  // Start fresh session if SCXML provided
153
238
  if (scxml) {
154
239
  await simulator.loadScxml(scxml);
@@ -193,19 +278,81 @@ export function createServer(config = {}) {
193
278
  }],
194
279
  };
195
280
  });
196
- server.tool('scxml_sim_get_state', 'Get the current simulation state without sending an event. Returns active states, variables, enabled events, execution mode, and full trace history.', {}, async () => {
197
- if (!simulator.isConnected()) {
198
- throw new Error('No active simulation session. Use scxml_sim_start first.');
281
+ server.tool('scxml_sim_timed_scenario', 'Run events with real-time delays between them. Unlike scxml_sim_scenario (instant), this lets delayed sends (<send delay="500ms"/>) fire between events perfect for demonstrating real-time behavior in the editor. Each event has a delay in ms before it is sent.', {
282
+ scxml: z.string().optional().describe('SCXML content (creates fresh session). Omit to use existing session.'),
283
+ events: z.array(z.object({
284
+ name: z.string().describe('Event name'),
285
+ delay: z.number().optional().default(0).describe('Delay in ms before sending this event (0 = immediate)'),
286
+ data: z.unknown().optional().describe('Optional event data'),
287
+ })).describe('Events with timing'),
288
+ }, async ({ scxml, events }) => {
289
+ const unavailable = await checkSimulator();
290
+ if (unavailable)
291
+ return unavailable;
292
+ // Start fresh session if SCXML provided
293
+ if (scxml) {
294
+ await simulator.loadScxml(scxml);
295
+ await simulator.start();
296
+ }
297
+ const perEvent = [];
298
+ for (const evt of events) {
299
+ // Wait the specified delay (real-time — delayed sends will fire)
300
+ if (evt.delay && evt.delay > 0) {
301
+ await new Promise(resolve => setTimeout(resolve, evt.delay));
302
+ }
303
+ try {
304
+ const result = await simulator.sendEvent(evt.name, evt.data);
305
+ perEvent.push({
306
+ event: evt.name,
307
+ delay: evt.delay || 0,
308
+ activeStates: result.state.activeStates,
309
+ variables: result.state.variables,
310
+ });
311
+ if (result.state.finished)
312
+ break;
313
+ }
314
+ catch (err) {
315
+ const msg = err instanceof Error ? err.message : String(err);
316
+ perEvent.push({
317
+ event: evt.name,
318
+ delay: evt.delay || 0,
319
+ activeStates: [],
320
+ variables: {},
321
+ });
322
+ return {
323
+ content: [{
324
+ type: 'text',
325
+ text: JSON.stringify({ perEvent, error: msg }, null, 2),
326
+ }],
327
+ };
328
+ }
199
329
  }
330
+ const finalState = await simulator.getState();
331
+ return {
332
+ content: [{
333
+ type: 'text',
334
+ text: JSON.stringify({
335
+ perEvent,
336
+ finalActiveStates: finalState.activeStates,
337
+ finalVariables: finalState.variables,
338
+ finished: finalState.finished,
339
+ }, null, 2),
340
+ }],
341
+ };
342
+ });
343
+ server.tool('scxml_sim_get_state', 'Get the current simulation state without sending an event. Returns active states, variables, enabled events, execution mode, and full trace history.', {}, async () => {
344
+ const unavailable = await requireSession();
345
+ if (unavailable)
346
+ return unavailable;
200
347
  const state = await simulator.getState();
201
348
  return {
202
349
  content: [{ type: 'text', text: JSON.stringify(state, null, 2) }],
203
350
  };
204
351
  });
205
352
  server.tool('scxml_sim_reset', 'Reset the simulation session to its initial state.', {}, async () => {
206
- if (!simulator.isConnected()) {
207
- throw new Error('No active simulation session. Use scxml_sim_start first.');
208
- }
353
+ const unavailable = await requireSession();
354
+ if (unavailable)
355
+ return unavailable;
209
356
  const state = await simulator.reset();
210
357
  return {
211
358
  content: [{ type: 'text', text: JSON.stringify(state, null, 2) }],
@@ -228,12 +375,18 @@ export function createServer(config = {}) {
228
375
  eventQueueDepth: z.number().optional(),
229
376
  }).optional().describe('Target-specific generation options'),
230
377
  }, async ({ scxml, target, options }) => {
378
+ const unavailable = await checkGenerator();
379
+ if (unavailable)
380
+ return unavailable;
231
381
  const result = await generator.generate(scxml, target, options);
232
382
  return {
233
383
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
234
384
  };
235
385
  });
236
386
  server.tool('scxml_list_targets', 'List available code generation targets and their options.', {}, async () => {
387
+ const unavailable = await checkGenerator();
388
+ if (unavailable)
389
+ return unavailable;
237
390
  const targets = await generator.listTargets();
238
391
  return {
239
392
  content: [{ type: 'text', text: JSON.stringify({ targets }, null, 2) }],
@@ -256,9 +409,9 @@ export function createServer(config = {}) {
256
409
  // Trace Management Tools
257
410
  // ═══════════════════════════════════════════════
258
411
  server.tool('scxml_trace_list', 'List all embedded traces in the current SCXML document. Requires an active simulation session.', {}, async () => {
259
- if (!simulator.isConnected()) {
260
- throw new Error('No active simulation session. Use scxml_sim_start first.');
261
- }
412
+ const unavailable = await requireSession();
413
+ if (unavailable)
414
+ return unavailable;
262
415
  const traces = await simulator.listTraces();
263
416
  return {
264
417
  content: [{ type: 'text', text: JSON.stringify({ traces }, null, 2) }],
@@ -269,9 +422,9 @@ export function createServer(config = {}) {
269
422
  description: z.string().optional().describe('Human-readable description of this trace'),
270
423
  content: z.string().optional().describe('Raw JSONL trace content to embed. If omitted, the current session execution history is used.'),
271
424
  }, async ({ name, description, content }) => {
272
- if (!simulator.isConnected()) {
273
- throw new Error('No active simulation session. Use scxml_sim_start first.');
274
- }
425
+ const unavailable = await requireSession();
426
+ if (unavailable)
427
+ return unavailable;
275
428
  const result = await simulator.saveTrace(name, { description, content });
276
429
  return {
277
430
  content: [{
@@ -286,9 +439,9 @@ export function createServer(config = {}) {
286
439
  server.tool('scxml_trace_delete', 'Delete an embedded trace from the SCXML document by name.', {
287
440
  name: z.string().describe('Trace name to delete'),
288
441
  }, async ({ name }) => {
289
- if (!simulator.isConnected()) {
290
- throw new Error('No active simulation session. Use scxml_sim_start first.');
291
- }
442
+ const unavailable = await requireSession();
443
+ if (unavailable)
444
+ return unavailable;
292
445
  await simulator.deleteTrace(name);
293
446
  return {
294
447
  content: [{ type: 'text', text: JSON.stringify({ deleted: name }, null, 2) }],
@@ -297,9 +450,9 @@ export function createServer(config = {}) {
297
450
  server.tool('scxml_trace_get', 'Get an embedded trace by name. Returns the parsed trace entries for inspection, comparison, or export.', {
298
451
  name: z.string().describe('Trace name to retrieve'),
299
452
  }, async ({ name }) => {
300
- if (!simulator.isConnected()) {
301
- throw new Error('No active simulation session. Use scxml_sim_start first.');
302
- }
453
+ const unavailable = await requireSession();
454
+ if (unavailable)
455
+ return unavailable;
303
456
  const result = await simulator.loadTrace(name);
304
457
  return {
305
458
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -312,9 +465,9 @@ export function createServer(config = {}) {
312
465
  name: z.string().describe('Embedded trace name to play'),
313
466
  speed: z.number().optional().describe('Playback speed multiplier (1.0 = real-time, 2.0 = 2x speed). Default: 1.0'),
314
467
  }, async ({ name, speed }) => {
315
- if (!simulator.isConnected()) {
316
- throw new Error('No active simulation session. Use scxml_sim_start first.');
317
- }
468
+ const unavailable = await requireSession();
469
+ if (unavailable)
470
+ return unavailable;
318
471
  const result = await simulator.playTrace(name, speed);
319
472
  return {
320
473
  content: [{
@@ -330,9 +483,9 @@ export function createServer(config = {}) {
330
483
  server.tool('scxml_trace_step', 'Step forward or backward through a loaded trace. Returns the current position and trace entry.', {
331
484
  delta: z.number().describe('Steps to move: positive = forward, negative = backward'),
332
485
  }, async ({ delta }) => {
333
- if (!simulator.isConnected()) {
334
- throw new Error('No active simulation session. Use scxml_sim_start first.');
335
- }
486
+ const unavailable = await requireSession();
487
+ if (unavailable)
488
+ return unavailable;
336
489
  const result = await simulator.stepTrace(delta);
337
490
  return {
338
491
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -342,9 +495,9 @@ export function createServer(config = {}) {
342
495
  name: z.string().describe('Variable name'),
343
496
  value: z.unknown().describe('New value for the variable'),
344
497
  }, async ({ name, value }) => {
345
- if (!simulator.isConnected()) {
346
- throw new Error('No active simulation session. Use scxml_sim_start first.');
347
- }
498
+ const unavailable = await requireSession();
499
+ if (unavailable)
500
+ return unavailable;
348
501
  await simulator.setVariable(name, value);
349
502
  return {
350
503
  content: [{ type: 'text', text: JSON.stringify({ set: name, value }, null, 2) }],
@@ -354,32 +507,18 @@ export function createServer(config = {}) {
354
507
  // Editor Interaction Tools
355
508
  // ═══════════════════════════════════════════════
356
509
  server.tool('editor_push_scxml', 'Push SCXML content to the VSCXML-Generator editor for the user to see and edit. The editor will update its SCXML input pane.', { scxml: z.string().describe('SCXML XML content to push to the editor') }, async ({ scxml }) => {
357
- if (!editor.isConnected()) {
358
- try {
359
- await editor.connect();
360
- }
361
- catch {
362
- return {
363
- content: [{ type: 'text', text: JSON.stringify({ success: false, error: 'Editor not connected. Is VSCXML-Generator running?' }) }],
364
- };
365
- }
366
- }
510
+ const unavailable = await checkEditor();
511
+ if (unavailable)
512
+ return unavailable;
367
513
  const result = await editor.pushScxml(scxml);
368
514
  return {
369
515
  content: [{ type: 'text', text: JSON.stringify(result) }],
370
516
  };
371
517
  });
372
518
  server.tool('editor_get_scxml', 'Get the current SCXML content from the VSCXML-Generator editor (what the user is currently editing).', {}, async () => {
373
- if (!editor.isConnected()) {
374
- try {
375
- await editor.connect();
376
- }
377
- catch {
378
- return {
379
- content: [{ type: 'text', text: JSON.stringify({ error: 'Editor not connected. Is VSCXML-Generator running?' }) }],
380
- };
381
- }
382
- }
519
+ const unavailable = await checkEditor();
520
+ if (unavailable)
521
+ return unavailable;
383
522
  const result = await editor.getScxml();
384
523
  return {
385
524
  content: [{ type: 'text', text: JSON.stringify(result) }],
@@ -390,16 +529,9 @@ export function createServer(config = {}) {
390
529
  severity: z.enum(['info', 'warning', 'error', 'success']).optional().default('info'),
391
530
  duration: z.number().optional().default(5000).describe('Duration in ms (0 = persistent)'),
392
531
  }, async ({ message, severity, duration }) => {
393
- if (!editor.isConnected()) {
394
- try {
395
- await editor.connect();
396
- }
397
- catch {
398
- return {
399
- content: [{ type: 'text', text: JSON.stringify({ success: false, error: 'Editor not connected' }) }],
400
- };
401
- }
402
- }
532
+ const unavailable = await checkEditor();
533
+ if (unavailable)
534
+ return unavailable;
403
535
  await editor.showNotification(message, severity, duration);
404
536
  return {
405
537
  content: [{ type: 'text', text: JSON.stringify({ success: true }) }],
@@ -411,14 +543,9 @@ export function createServer(config = {}) {
411
543
  duration: z.number().optional().describe('Duration in ms (0 = persistent until cleared)'),
412
544
  clear: z.boolean().optional().describe('Clear existing highlights first'),
413
545
  }, async ({ states, color, duration, clear }) => {
414
- if (!editor.isConnected()) {
415
- try {
416
- await editor.connect();
417
- }
418
- catch {
419
- return { content: [{ type: 'text', text: JSON.stringify({ success: false, error: 'Editor not connected' }) }] };
420
- }
421
- }
546
+ const unavailable = await checkEditor();
547
+ if (unavailable)
548
+ return unavailable;
422
549
  const resolvedColor = color ? (HIGHLIGHT_PRESETS[color.toLowerCase()] || color) : undefined;
423
550
  await editor.highlight(states, { color: resolvedColor, duration, clear });
424
551
  return { content: [{ type: 'text', text: JSON.stringify({ success: true }) }] };
@@ -429,28 +556,18 @@ export function createServer(config = {}) {
429
556
  color: z.string().optional().describe('Note background color (default: light blue)'),
430
557
  visibleWhen: z.array(z.string()).optional().describe('Only show when these state IDs are active'),
431
558
  }, async ({ content, attachedTo, color, visibleWhen }) => {
432
- if (!editor.isConnected()) {
433
- try {
434
- await editor.connect();
435
- }
436
- catch {
437
- return { content: [{ type: 'text', text: JSON.stringify({ success: false, error: 'Editor not connected' }) }] };
438
- }
439
- }
559
+ const unavailable = await checkEditor();
560
+ if (unavailable)
561
+ return unavailable;
440
562
  const result = await editor.addNote(content, { attachedTo, color, visibleWhen });
441
563
  return { content: [{ type: 'text', text: JSON.stringify({ success: true, noteId: result.noteId }) }] };
442
564
  });
443
565
  server.tool('editor_remove_notes', 'Remove notes previously added by MCP from the editor canvas. Call with no noteIds to remove all MCP-added notes.', {
444
566
  noteIds: z.array(z.string()).optional().describe('Specific note IDs to remove (omit to remove all MCP notes)'),
445
567
  }, async ({ noteIds }) => {
446
- if (!editor.isConnected()) {
447
- try {
448
- await editor.connect();
449
- }
450
- catch {
451
- return { content: [{ type: 'text', text: JSON.stringify({ success: false, error: 'Editor not connected' }) }] };
452
- }
453
- }
568
+ const unavailable = await checkEditor();
569
+ if (unavailable)
570
+ return unavailable;
454
571
  await editor.removeNotes(noteIds);
455
572
  return { content: [{ type: 'text', text: JSON.stringify({ success: true }) }] };
456
573
  });
@@ -460,14 +577,9 @@ export function createServer(config = {}) {
460
577
  fitAll: z.boolean().optional().describe('Fit entire diagram in view'),
461
578
  zoom: z.number().optional().describe('Zoom level (default: 1.0)'),
462
579
  }, async ({ target, fitStates, fitAll, zoom }) => {
463
- if (!editor.isConnected()) {
464
- try {
465
- await editor.connect();
466
- }
467
- catch {
468
- return { content: [{ type: 'text', text: JSON.stringify({ success: false, error: 'Editor not connected' }) }] };
469
- }
470
- }
580
+ const unavailable = await checkEditor();
581
+ if (unavailable)
582
+ return unavailable;
471
583
  await editor.navigate({ target, fitStates, fitAll, zoom });
472
584
  return { content: [{ type: 'text', text: JSON.stringify({ success: true }) }] };
473
585
  });
@@ -475,14 +587,9 @@ export function createServer(config = {}) {
475
587
  filePath: z.string().describe('Absolute file path to save to'),
476
588
  content: z.string().optional().describe('SCXML content to save. If omitted, fetches current content from the editor.'),
477
589
  }, async ({ filePath, content }) => {
478
- if (!editor.isConnected()) {
479
- try {
480
- await editor.connect();
481
- }
482
- catch {
483
- return { content: [{ type: 'text', text: JSON.stringify({ success: false, error: 'Editor not connected' }) }] };
484
- }
485
- }
590
+ const unavailable = await checkEditor();
591
+ if (unavailable)
592
+ return unavailable;
486
593
  // If no content provided, get it from the editor
487
594
  let scxmlContent = content;
488
595
  if (!scxmlContent) {
@@ -495,14 +602,9 @@ export function createServer(config = {}) {
495
602
  server.tool('editor_load_file', 'Load an SCXML file from disk into the editor. The editor will display the loaded content.', {
496
603
  filePath: z.string().describe('Absolute file path to load'),
497
604
  }, async ({ filePath }) => {
498
- if (!editor.isConnected()) {
499
- try {
500
- await editor.connect();
501
- }
502
- catch {
503
- return { content: [{ type: 'text', text: JSON.stringify({ success: false, error: 'Editor not connected' }) }] };
504
- }
505
- }
605
+ const unavailable = await checkEditor();
606
+ if (unavailable)
607
+ return unavailable;
506
608
  const result = await editor.loadFile(filePath);
507
609
  return {
508
610
  content: [{
@@ -516,14 +618,9 @@ export function createServer(config = {}) {
516
618
  };
517
619
  });
518
620
  server.tool('editor_screenshot', 'Capture a screenshot of the VSCXML-Generator editor window. Returns a base64-encoded PNG image.', {}, async () => {
519
- if (!editor.isConnected()) {
520
- try {
521
- await editor.connect();
522
- }
523
- catch {
524
- return { content: [{ type: 'text', text: JSON.stringify({ error: 'Editor not connected' }) }] };
525
- }
526
- }
621
+ const unavailable = await checkEditor();
622
+ if (unavailable)
623
+ return unavailable;
527
624
  const result = await editor.screenshot();
528
625
  return {
529
626
  content: [{
@@ -536,26 +633,16 @@ export function createServer(config = {}) {
536
633
  server.tool('editor_connect_simulator', 'Connect the editor to the simulator backend so it shows live state highlights during MCP-driven simulation.', {
537
634
  port: z.number().optional().describe('Simulator WebSocket port (default: 48621)'),
538
635
  }, async ({ port }) => {
539
- if (!editor.isConnected()) {
540
- try {
541
- await editor.connect();
542
- }
543
- catch {
544
- return { content: [{ type: 'text', text: JSON.stringify({ success: false, error: 'Editor not connected' }) }] };
545
- }
546
- }
636
+ const unavailable = await checkEditor();
637
+ if (unavailable)
638
+ return unavailable;
547
639
  const result = await editor.connectSimulator(port);
548
640
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
549
641
  });
550
642
  server.tool('editor_get_selection', 'Get the current selection state in the editor — selected states, transitions, and MCP highlights (with colors). Also includes the latest buffered selection event from user clicks.', {}, async () => {
551
- if (!editor.isConnected()) {
552
- try {
553
- await editor.connect();
554
- }
555
- catch {
556
- return { content: [{ type: 'text', text: JSON.stringify({ error: 'Editor not connected' }) }] };
557
- }
558
- }
643
+ const unavailable = await checkEditor();
644
+ if (unavailable)
645
+ return unavailable;
559
646
  const result = await editor.getSelection();
560
647
  const buffered = editor.getLastSelection();
561
648
  return {
@@ -566,14 +653,9 @@ export function createServer(config = {}) {
566
653
  };
567
654
  });
568
655
  server.tool('editor_get_viewport', 'Get the editor viewport: visible area, zoom level, pan offset. Also returns the latest buffered selection and document change events.', {}, async () => {
569
- if (!editor.isConnected()) {
570
- try {
571
- await editor.connect();
572
- }
573
- catch {
574
- return { content: [{ type: 'text', text: JSON.stringify({ error: 'Editor not connected' }) }] };
575
- }
576
- }
656
+ const unavailable = await checkEditor();
657
+ if (unavailable)
658
+ return unavailable;
577
659
  const viewport = await editor.getViewport();
578
660
  return {
579
661
  content: [{
@@ -587,29 +669,79 @@ export function createServer(config = {}) {
587
669
  };
588
670
  });
589
671
  // ═══════════════════════════════════════════════
672
+ // Editor Layout & Editing Tools
673
+ // ═══════════════════════════════════════════════
674
+ server.tool('editor_auto_layout', 'Trigger automatic layout of the diagram in the editor. Rearranges states for clean visual presentation.', {
675
+ algorithm: z.enum(['hierarchical', 'grid']).optional().describe('Layout algorithm (default: hierarchical)'),
676
+ selectedOnly: z.boolean().optional().describe('Only layout selected states (default: false)'),
677
+ }, async ({ algorithm, selectedOnly }) => {
678
+ const unavailable = await checkEditor();
679
+ if (unavailable)
680
+ return unavailable;
681
+ const result = await editor.autoLayout({ algorithm, selectedOnly });
682
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
683
+ });
684
+ server.tool('editor_add_state', 'Add a new state to the diagram at the given canvas coordinates.', {
685
+ x: z.number().describe('X position on canvas'),
686
+ y: z.number().describe('Y position on canvas'),
687
+ stateType: z.enum(['simple', 'compound', 'parallel', 'initial', 'final', 'history', 'history-deep']).optional().default('simple').describe('State type'),
688
+ }, async ({ x, y, stateType }) => {
689
+ const unavailable = await checkEditor();
690
+ if (unavailable)
691
+ return unavailable;
692
+ const result = await editor.addState(x, y, stateType);
693
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
694
+ });
695
+ server.tool('editor_add_transition', 'Add a transition between two states in the editor.', {
696
+ source: z.string().describe('Source state ID'),
697
+ target: z.string().describe('Target state ID'),
698
+ }, async ({ source, target }) => {
699
+ const unavailable = await checkEditor();
700
+ if (unavailable)
701
+ return unavailable;
702
+ const result = await editor.addTransition(source, target);
703
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
704
+ });
705
+ server.tool('editor_set_property', 'Set properties on a state or transition. For states: label, color, x, y, width, height, onentry, onexit. For transitions: event, cond, actions, type.', {
706
+ elementId: z.string().describe('State or transition ID'),
707
+ elementType: z.enum(['state', 'transition']).describe('Element type'),
708
+ properties: z.record(z.unknown()).describe('Properties to set (e.g. { "label": "Idle", "color": "#3b82f6" })'),
709
+ }, async ({ elementId, elementType, properties }) => {
710
+ const unavailable = await checkEditor();
711
+ if (unavailable)
712
+ return unavailable;
713
+ const result = await editor.setProperty(elementId, elementType, properties);
714
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
715
+ });
716
+ server.tool('editor_add_image', 'Add an image to the editor canvas. Use for explanatory diagrams, icons, or annotations. The image data must be base64-encoded.', {
717
+ data: z.string().describe('Base64-encoded image data (no data: prefix)'),
718
+ mimeType: z.string().optional().default('image/png').describe('MIME type (image/png, image/jpeg, image/svg+xml)'),
719
+ x: z.number().optional().describe('X position on canvas'),
720
+ y: z.number().optional().describe('Y position on canvas'),
721
+ width: z.number().optional().describe('Display width (auto-scaled if omitted)'),
722
+ height: z.number().optional().describe('Display height (auto-scaled if omitted)'),
723
+ attachedTo: z.string().optional().describe('State ID to attach the image to'),
724
+ }, async ({ data, mimeType, x, y, width, height, attachedTo }) => {
725
+ const unavailable = await checkEditor();
726
+ if (unavailable)
727
+ return unavailable;
728
+ const result = await editor.addImage(data, { mimeType, x, y, width, height, attachedTo });
729
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
730
+ });
731
+ // ═══════════════════════════════════════════════
590
732
  // Export Tools
591
733
  // ═══════════════════════════════════════════════
592
734
  server.tool('editor_export_svg', 'Export the current diagram from the editor as a complete SVG. Returns parseable text the agent can read directly — contains state IDs, labels, positions, transitions, colors.', {}, async () => {
593
- if (!editor.isConnected()) {
594
- try {
595
- await editor.connect();
596
- }
597
- catch {
598
- return { content: [{ type: 'text', text: JSON.stringify({ error: 'Editor not connected' }) }] };
599
- }
600
- }
735
+ const unavailable = await checkEditor();
736
+ if (unavailable)
737
+ return unavailable;
601
738
  const result = await editor.exportSvg();
602
739
  return { content: [{ type: 'text', text: result.svg }] };
603
740
  });
604
741
  server.tool('editor_export_png', 'Export the current diagram from the editor as a full PNG image at 2x resolution. Unlike editor_screenshot (viewport only), this renders the entire diagram.', {}, async () => {
605
- if (!editor.isConnected()) {
606
- try {
607
- await editor.connect();
608
- }
609
- catch {
610
- return { content: [{ type: 'text', text: JSON.stringify({ error: 'Editor not connected' }) }] };
611
- }
612
- }
742
+ const unavailable = await checkEditor();
743
+ if (unavailable)
744
+ return unavailable;
613
745
  const result = await editor.exportPng();
614
746
  return {
615
747
  content: [{ type: 'image', data: result.data, mimeType: 'image/png' }],
@@ -621,14 +753,9 @@ export function createServer(config = {}) {
621
753
  selectedTraceIds: z.array(z.string()).optional()
622
754
  .describe('Trace IDs to include when exportMode is "traces" or "both"'),
623
755
  }, async ({ exportMode, selectedTraceIds }) => {
624
- if (!editor.isConnected()) {
625
- try {
626
- await editor.connect();
627
- }
628
- catch {
629
- return { content: [{ type: 'text', text: JSON.stringify({ error: 'Editor not connected' }) }] };
630
- }
631
- }
756
+ const unavailable = await checkEditor();
757
+ if (unavailable)
758
+ return unavailable;
632
759
  const result = await editor.exportPlayerHtml({ exportMode, selectedTraceIds });
633
760
  return { content: [{ type: 'text', text: result.html }] };
634
761
  });
@@ -641,21 +768,46 @@ export function createServer(config = {}) {
641
768
  // ═══════════════════════════════════════════════
642
769
  // Connection Status Tool
643
770
  // ═══════════════════════════════════════════════
644
- server.tool('scxml_status', 'Check the connection status of VSCXML backends (generator, simulator, editor).', {}, async () => {
645
- const [genAvail, simAvail, editorStatus] = await Promise.all([
771
+ server.tool('scxml_status', 'Check the connection status of VSCXML backends (generator, simulator, editor). Reports URLs, available tool groups, and how to start missing backends. Call this first to know which tools are available.', {}, async () => {
772
+ const [genAvail, simAvail, editorAvail] = await Promise.all([
646
773
  generator.isAvailable(),
647
774
  simulator.isAvailable(),
648
- editor.getStatus(),
775
+ editor.isAvailable(),
649
776
  ]);
777
+ const editorStatus = editorAvail
778
+ ? await editor.getStatus()
779
+ : { connected: false, hasDocument: false, documentName: null };
780
+ // Scan discovery files for all known instances
781
+ const allGenerators = readDiscoveryFiles('generator');
782
+ const allSimulators = readDiscoveryFiles('simulator');
783
+ const allEditors = readDiscoveryFiles('editor');
784
+ const result = {
785
+ generator: {
786
+ connected: genAvail,
787
+ url: generator.getUrl(),
788
+ tools: GENERATOR_TOOLS,
789
+ instances: allGenerators,
790
+ startHint: genAvail ? undefined : 'Run: vscxml-generator-cli serve --port 48620 --cors',
791
+ },
792
+ simulator: {
793
+ connected: simAvail,
794
+ url: simulator.getUrl(),
795
+ sessionId: simulator.getSessionId(),
796
+ tools: SIMULATOR_TOOLS,
797
+ instances: allSimulators,
798
+ startHint: simAvail ? undefined : 'Start the simulator or open it from VSCXML-Editor',
799
+ },
800
+ editor: {
801
+ ...editorStatus,
802
+ url: editor.getUrl(),
803
+ tools: EDITOR_TOOLS,
804
+ instances: allEditors,
805
+ startHint: editorStatus.connected ? undefined : 'Start the VSCXML-Editor desktop application',
806
+ },
807
+ summary: buildSummary(genAvail, simAvail, editorStatus.connected),
808
+ };
650
809
  return {
651
- content: [{
652
- type: 'text',
653
- text: JSON.stringify({
654
- generator: { connected: genAvail },
655
- simulator: { connected: simAvail, sessionId: simulator.getSessionId() },
656
- editor: editorStatus,
657
- }, null, 2),
658
- }],
810
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
659
811
  };
660
812
  });
661
813
  return server;