@lightward/mechanic-mcp 0.1.3 → 0.1.4

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.
Binary file
@@ -1,9 +1,9 @@
1
1
  {
2
- "builtAt": "2025-12-04T22:15:41.397Z",
2
+ "builtAt": "2026-03-29T04:00:47.709Z",
3
3
  "counts": {
4
- "docs": 314,
5
- "tasks": 357,
6
- "total": 671
4
+ "docs": 332,
5
+ "tasks": 360,
6
+ "total": 692
7
7
  },
8
8
  "sources": {
9
9
  "docsPath": "/Users/msodomsky/dev/mechanic-docs",
Binary file
@@ -0,0 +1,631 @@
1
+ // Task development helpers: lint + preview simulation.
2
+ // These utilities are deliberately conservative and only cover a small subset
3
+ // of Mechanic's runtime to offer quick, offline feedback.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { Liquid } from 'liquidjs';
8
+ import { inlineTaskSchema, lintTaskOutputSchema, previewTaskOutputSchema } from './schemas.js';
9
+ const PREVIEW_FIXTURES = {
10
+ 'shopify/orders/create': {
11
+ id: 'gid://shopify/Order/1234567890',
12
+ name: '#1001',
13
+ email: 'customer@example.com',
14
+ source_name: 'web',
15
+ note_attributes: [],
16
+ line_items: [
17
+ {
18
+ id: 'gid://shopify/LineItem/123',
19
+ sku: 'SKU-123',
20
+ quantity: 1,
21
+ price_set: { shop_money: { amount: '12.00', currency_code: 'USD' } },
22
+ },
23
+ ],
24
+ },
25
+ 'shopify/customers/create': {
26
+ id: 'gid://shopify/Customer/1234567890',
27
+ email: 'customer@example.com',
28
+ first_name: 'Ada',
29
+ last_name: 'Lovelace',
30
+ tags: ['vip'],
31
+ note: 'Preview payload',
32
+ },
33
+ 'user/event/preview': {
34
+ message: 'Hello from a preview event',
35
+ },
36
+ };
37
+ const ALLOWED_ACTIONS = new Set([
38
+ 'echo',
39
+ 'email',
40
+ 'event',
41
+ 'shopify',
42
+ 'cache',
43
+ 'files',
44
+ 'ftp',
45
+ 'http',
46
+ 'slack',
47
+ 'google',
48
+ 'google_drive',
49
+ 'google_sheets',
50
+ 'flow',
51
+ 'report_toaster',
52
+ 'airtable',
53
+ 'oauth2',
54
+ 'general_o_auth2',
55
+ ]);
56
+ const DEFAULT_CUSTOM_TOPIC = 'user/event/preview';
57
+ const BASE_LIQUID_TAGS = new Set([
58
+ 'assign',
59
+ 'capture',
60
+ 'increment',
61
+ 'decrement',
62
+ 'if',
63
+ 'elsif',
64
+ 'else',
65
+ 'unless',
66
+ 'case',
67
+ 'when',
68
+ 'endcase',
69
+ 'endif',
70
+ 'endunless',
71
+ 'for',
72
+ 'endfor',
73
+ 'cycle',
74
+ 'tablerow',
75
+ 'endtablerow',
76
+ 'break',
77
+ 'continue',
78
+ 'comment',
79
+ 'endcomment',
80
+ 'raw',
81
+ 'endraw',
82
+ 'include',
83
+ 'render',
84
+ 'echo',
85
+ 'liquid',
86
+ ]);
87
+ const MECHANIC_TAGS = new Set(['action', 'log', 'error', 'permissions']);
88
+ const BASE_LIQUID_FILTERS = new Set([
89
+ 'abs',
90
+ 'append',
91
+ 'at_least',
92
+ 'at_most',
93
+ 'capitalize',
94
+ 'ceil',
95
+ 'compact',
96
+ 'concat',
97
+ 'date',
98
+ 'default',
99
+ 'divided_by',
100
+ 'downcase',
101
+ 'escape',
102
+ 'escape_once',
103
+ 'first',
104
+ 'floor',
105
+ 'join',
106
+ 'last',
107
+ 'lstrip',
108
+ 'map',
109
+ 'minus',
110
+ 'modulo',
111
+ 'newline_to_br',
112
+ 'plus',
113
+ 'prepend',
114
+ 'remove',
115
+ 'remove_first',
116
+ 'replace',
117
+ 'replace_first',
118
+ 'reverse',
119
+ 'round',
120
+ 'size',
121
+ 'slice',
122
+ 'sort',
123
+ 'sort_natural',
124
+ 'split',
125
+ 'strip',
126
+ 'strip_html',
127
+ 'strip_newlines',
128
+ 'times',
129
+ 'truncate',
130
+ 'truncatewords',
131
+ 'uniq',
132
+ 'upcase',
133
+ 'url_decode',
134
+ 'url_encode',
135
+ ]);
136
+ const MECHANIC_FILTERS = new Set([
137
+ 'array',
138
+ 'base64',
139
+ 'browser',
140
+ 'compact',
141
+ 'compress',
142
+ 'csv',
143
+ 'currency',
144
+ 'date',
145
+ 'digest',
146
+ 'e164',
147
+ 'graphql',
148
+ 'hash',
149
+ 'img_url',
150
+ 'json',
151
+ 'regexp',
152
+ 'money',
153
+ 'newline',
154
+ 'shop',
155
+ 'shopify',
156
+ 'sort',
157
+ 'string',
158
+ 'slice',
159
+ 'tag',
160
+ 'tags',
161
+ 'where',
162
+ 'xml',
163
+ ]);
164
+ function loadDynamicSamples() {
165
+ const here = path.dirname(fileURLToPath(import.meta.url));
166
+ const candidates = [
167
+ process.env.MECHANIC_EVENT_SAMPLES_PATH,
168
+ // when running from repo root
169
+ path.resolve(here, '..', 'mechanic-api', 'app', 'lib', 'mechanic', 'event_topics', 'data_samples'),
170
+ // when running from built dist/
171
+ path.resolve(here, '..', 'data', 'event_samples'),
172
+ // when running from project root in dev
173
+ path.resolve(process.cwd(), 'dist', 'data', 'event_samples'),
174
+ ].filter(Boolean);
175
+ const merged = {};
176
+ for (const candidate of candidates) {
177
+ try {
178
+ if (!fs.existsSync(candidate))
179
+ continue;
180
+ const files = fs.readdirSync(candidate, { withFileTypes: true });
181
+ files.forEach((dirent) => {
182
+ const subpath = path.join(candidate, dirent.name);
183
+ if (dirent.isDirectory()) {
184
+ const subFiles = fs.readdirSync(subpath, { withFileTypes: true });
185
+ subFiles.forEach((subDirent) => {
186
+ const nestedPath = path.join(subpath, subDirent.name);
187
+ if (subDirent.isDirectory()) {
188
+ fs.readdirSync(nestedPath, { withFileTypes: true }).forEach((leafDirent) => {
189
+ if (leafDirent.isFile() && leafDirent.name.endsWith('.json')) {
190
+ const topic = `${dirent.name}/${subDirent.name}/${leafDirent.name.replace(/\.json$/, '')}`;
191
+ try {
192
+ const raw = fs.readFileSync(path.join(nestedPath, leafDirent.name), 'utf-8');
193
+ merged[topic] = JSON.parse(raw);
194
+ }
195
+ catch {
196
+ /* ignore parsing errors */
197
+ }
198
+ }
199
+ });
200
+ }
201
+ else if (subDirent.isFile() && subDirent.name.endsWith('.json')) {
202
+ const topic = `${dirent.name}/${subDirent.name.replace(/\.json$/, '')}`;
203
+ try {
204
+ const raw = fs.readFileSync(nestedPath, 'utf-8');
205
+ merged[topic] = JSON.parse(raw);
206
+ }
207
+ catch {
208
+ /* ignore parsing errors */
209
+ }
210
+ }
211
+ });
212
+ }
213
+ else if (dirent.isFile() && dirent.name.endsWith('.json')) {
214
+ const topic = dirent.name.replace(/\.json$/, '');
215
+ try {
216
+ const raw = fs.readFileSync(subpath, 'utf-8');
217
+ merged[topic] = JSON.parse(raw);
218
+ }
219
+ catch {
220
+ /* ignore parsing errors */
221
+ }
222
+ }
223
+ });
224
+ }
225
+ catch {
226
+ // ignore and continue
227
+ }
228
+ }
229
+ return merged;
230
+ }
231
+ const DYNAMIC_FIXTURES = loadDynamicSamples();
232
+ const liquidEngine = new Liquid({
233
+ cache: false,
234
+ dynamicPartials: false,
235
+ strictFilters: false,
236
+ strictVariables: false,
237
+ });
238
+ // Register Mechanic tags as no-ops for syntax validation
239
+ // Register Mechanic tags in liquidjs (lightweight stubs)
240
+ liquidEngine.registerTag('action', {
241
+ parse: function (tagToken, remainTokens) {
242
+ this.tokens = remainTokens;
243
+ this.args = tagToken.args;
244
+ this.name = tagToken.name;
245
+ },
246
+ render: async function (ctx) {
247
+ // Do not execute; just mark that we saw an action. Parsing/rendering is handled separately.
248
+ return '';
249
+ },
250
+ });
251
+ liquidEngine.registerTag('log', {
252
+ parse: function (tagToken, remainTokens) {
253
+ this.tokens = remainTokens;
254
+ this.args = tagToken.args;
255
+ this.name = tagToken.name;
256
+ },
257
+ render: async function () {
258
+ return '';
259
+ },
260
+ });
261
+ liquidEngine.registerTag('error', {
262
+ parse: function (tagToken, remainTokens) {
263
+ this.tokens = remainTokens;
264
+ this.args = tagToken.args;
265
+ this.name = tagToken.name;
266
+ },
267
+ render: async function () {
268
+ return '';
269
+ },
270
+ });
271
+ liquidEngine.registerTag('permissions', {
272
+ parse: function (tagToken, remainTokens) {
273
+ this.tokens = remainTokens;
274
+ this.args = tagToken.args;
275
+ this.name = tagToken.name;
276
+ },
277
+ render: async function () {
278
+ return '';
279
+ },
280
+ });
281
+ // Register stub filters for Mechanic filters so syntax parsing succeeds
282
+ [...MECHANIC_FILTERS].forEach((filterName) => {
283
+ liquidEngine.registerFilter(filterName, (v) => v);
284
+ });
285
+ const PREVIEW_WARNINGS = [
286
+ 'Simulator is approximate: Liquid control flow, drops, and Shopify/cache lookups are not fully emulated.',
287
+ 'Actions are captured statically from the template; conditional logic is not executed.',
288
+ ];
289
+ function resolveTaskFromStore(handle, store) {
290
+ const normalized = handle.startsWith('task:') ? handle : `task:${handle}`;
291
+ const record = store.records.find((r) => r.id === normalized);
292
+ if (!record || record.kind !== 'task')
293
+ return undefined;
294
+ return {
295
+ id: record.id,
296
+ name: record.title,
297
+ tags: record.tags,
298
+ script: record.script ?? record.content,
299
+ online_store_javascript: record.online_store_javascript || undefined,
300
+ order_status_javascript: record.order_status_javascript || undefined,
301
+ subscriptions: record.events,
302
+ subscriptions_template: record.subscriptions_template,
303
+ options: record.options,
304
+ };
305
+ }
306
+ export function resolveTask(input, store) {
307
+ if (input.handle) {
308
+ const resolved = resolveTaskFromStore(input.handle, store);
309
+ if (resolved)
310
+ return resolved;
311
+ }
312
+ if (input.task) {
313
+ const parsed = inlineTaskSchema.parse(input.task);
314
+ return {
315
+ id: parsed.id,
316
+ name: parsed.name,
317
+ tags: parsed.tags,
318
+ script: parsed.script,
319
+ online_store_javascript: parsed.online_store_javascript,
320
+ order_status_javascript: parsed.order_status_javascript,
321
+ subscriptions: parsed.subscriptions,
322
+ subscriptions_template: parsed.subscriptions_template,
323
+ options: parsed.options,
324
+ preview_event_definitions: parsed.preview_event_definitions,
325
+ };
326
+ }
327
+ throw new Error('No task provided. Supply a handle or a task object.');
328
+ }
329
+ function evaluateExpression(expression, context, warnings) {
330
+ // Support simple dot/bracket paths and a tiny filter set.
331
+ const [rawPath, ...filters] = expression.split('|').map((part) => part.trim());
332
+ const path = rawPath.replace(/\[\"?([^\]]+?)\"?\]/g, '.$1');
333
+ let value = path.split('.').reduce((acc, key) => (acc && key in acc ? acc[key] : undefined), context);
334
+ filters.forEach((filter) => {
335
+ if (filter === 'json') {
336
+ value = JSON.stringify(value);
337
+ }
338
+ else if (filter === 'upcase' && typeof value === 'string') {
339
+ value = value.toUpperCase();
340
+ }
341
+ else if (filter === 'downcase' && typeof value === 'string') {
342
+ value = value.toLowerCase();
343
+ }
344
+ else if (filter.length > 0) {
345
+ warnings.push(`Unsupported filter "${filter}"`);
346
+ }
347
+ });
348
+ return value === undefined ? '' : value;
349
+ }
350
+ function renderTemplate(template, context, warnings) {
351
+ return template.replace(/{{\s*([^}]+)\s*}}/g, (_match, expr) => {
352
+ const value = evaluateExpression(expr, context, warnings);
353
+ return typeof value === 'string' ? value : JSON.stringify(value);
354
+ });
355
+ }
356
+ function parseActions(script, context, warnings) {
357
+ const actions = [];
358
+ const blockRegex = /{%\s*action\s+"([^"]+)"[^%]*%}([\s\S]*?){%\s*endaction\s*%}/gi;
359
+ let match;
360
+ while ((match = blockRegex.exec(script)) !== null) {
361
+ const [, type, bodyRaw] = match;
362
+ const rendered = renderTemplate(bodyRaw.trim(), context, warnings);
363
+ let parsed;
364
+ try {
365
+ parsed = JSON.parse(rendered);
366
+ }
367
+ catch {
368
+ parsed = undefined;
369
+ }
370
+ actions.push({
371
+ type,
372
+ raw: bodyRaw.trim(),
373
+ rendered,
374
+ parsed,
375
+ });
376
+ }
377
+ const inlineRegex = /{%\s*action\s+"([^"]+)"\s*,\s*([^%]+)%}/gi;
378
+ while ((match = inlineRegex.exec(script)) !== null) {
379
+ const [, type, inlineRaw] = match;
380
+ const rendered = renderTemplate(inlineRaw.trim(), context, warnings);
381
+ let parsed;
382
+ try {
383
+ parsed = JSON.parse(rendered);
384
+ }
385
+ catch {
386
+ parsed = rendered;
387
+ }
388
+ actions.push({
389
+ type,
390
+ raw: inlineRaw.trim(),
391
+ rendered,
392
+ parsed,
393
+ });
394
+ }
395
+ return actions;
396
+ }
397
+ function parseTagNames(script) {
398
+ const tags = [];
399
+ const tagRegex = /{%-?\s*([a-zA-Z_]+)\b[^%]*%}/g;
400
+ let match;
401
+ while ((match = tagRegex.exec(script)) !== null) {
402
+ tags.push(match[1]);
403
+ }
404
+ return tags;
405
+ }
406
+ function parseFilterNames(script) {
407
+ const filters = [];
408
+ const filterRegex = /\|\s*([a-zA-Z_][a-zA-Z0-9_]*)/g;
409
+ let match;
410
+ while ((match = filterRegex.exec(script)) !== null) {
411
+ filters.push(match[1]);
412
+ }
413
+ return filters;
414
+ }
415
+ function parseLogs(script, context, warnings) {
416
+ const logs = [];
417
+ const logRegex = /{%\s*log\s+([^%]+)%}/gi;
418
+ let match;
419
+ while ((match = logRegex.exec(script)) !== null) {
420
+ const [, payload] = match;
421
+ const raw = payload.trim();
422
+ const rendered = renderTemplate(raw, context, warnings);
423
+ const evaluated = evaluateExpression(raw, context, warnings);
424
+ const value = evaluated === ''
425
+ ? rendered
426
+ : typeof evaluated === 'string'
427
+ ? evaluated
428
+ : JSON.stringify(evaluated);
429
+ logs.push(value);
430
+ }
431
+ return logs;
432
+ }
433
+ function detectTopic(task, topicOverride, payloadTopic) {
434
+ if (topicOverride?.trim())
435
+ return topicOverride.trim();
436
+ if (payloadTopic?.trim())
437
+ return payloadTopic.trim();
438
+ if (task.subscriptions && task.subscriptions.length > 0)
439
+ return task.subscriptions[0];
440
+ if (task.subscriptions_template) {
441
+ const lines = task.subscriptions_template
442
+ .split('\n')
443
+ .map((line) => line.trim())
444
+ .filter(Boolean);
445
+ if (lines.length > 0)
446
+ return lines[0];
447
+ }
448
+ return DEFAULT_CUSTOM_TOPIC;
449
+ }
450
+ function resolvePayload(topic, payload) {
451
+ if (payload)
452
+ return payload;
453
+ if (DYNAMIC_FIXTURES[topic])
454
+ return DYNAMIC_FIXTURES[topic];
455
+ if (PREVIEW_FIXTURES[topic])
456
+ return PREVIEW_FIXTURES[topic];
457
+ return { message: `Preview payload for ${topic}` };
458
+ }
459
+ function normalizeTopic(topicWithOffset) {
460
+ const offsetIndex = topicWithOffset.indexOf('+');
461
+ return offsetIndex >= 0 ? topicWithOffset.slice(0, offsetIndex) : topicWithOffset;
462
+ }
463
+ function isLikelyValidTopic(topic) {
464
+ // Expect domain/subject/verb
465
+ return /^[a-z0-9_]+\/[a-z0-9_]+\/[a-z0-9_]+$/i.test(topic);
466
+ }
467
+ async function renderLiquid(template, context) {
468
+ return liquidEngine.parseAndRender(template, context);
469
+ }
470
+ async function renderSubscriptionsTemplate(template, context, errors) {
471
+ try {
472
+ const rendered = await renderLiquid(template, context);
473
+ return rendered
474
+ .split('\n')
475
+ .map((line) => line.trim())
476
+ .filter(Boolean);
477
+ }
478
+ catch (error) {
479
+ const message = error instanceof Error ? error.message : String(error);
480
+ errors.push(`Subscriptions template Liquid error: ${message}`);
481
+ return [];
482
+ }
483
+ }
484
+ function validateLiquidSyntax(script, errors, warnings) {
485
+ try {
486
+ void liquidEngine.parse(script);
487
+ }
488
+ catch (error) {
489
+ const message = error instanceof Error ? error.message : String(error);
490
+ warnings.push(`Liquid syntax warning: ${message}`);
491
+ }
492
+ }
493
+ export function lintTask(task) {
494
+ const errors = [];
495
+ const warnings = [...PREVIEW_WARNINGS];
496
+ const notes = [];
497
+ if (!task.script || task.script.trim().length === 0) {
498
+ errors.push('Task is missing a script.');
499
+ }
500
+ if (task.script) {
501
+ validateLiquidSyntax(task.script, errors, warnings);
502
+ }
503
+ if (task.subscriptions_template) {
504
+ validateLiquidSyntax(task.subscriptions_template, errors, warnings);
505
+ }
506
+ if (!task.subscriptions?.length && !task.subscriptions_template) {
507
+ warnings.push(`No subscriptions provided; preview will default to ${DEFAULT_CUSTOM_TOPIC}.`);
508
+ }
509
+ const topicsToCheck = (task.subscriptions || []).map((sub) => normalizeTopic(sub));
510
+ topicsToCheck.forEach((topic) => {
511
+ if (!isLikelyValidTopic(topic)) {
512
+ warnings.push(`Subscription topic "${topic}" is not in domain/subject/verb form (e.g., shopify/orders/create).`);
513
+ }
514
+ else if (!topic.startsWith('shopify/') && !topic.startsWith('mechanic/') && !topic.startsWith('user/')) {
515
+ warnings.push(`Subscription topic "${topic}" uses an uncommon domain; expected shopify/, mechanic/, or user/.`);
516
+ }
517
+ });
518
+ if (task.preview_event_definitions) {
519
+ task.preview_event_definitions.forEach((definition, index) => {
520
+ const topic = normalizeTopic(definition.event_attributes?.topic || '');
521
+ if (topic && !isLikelyValidTopic(topic)) {
522
+ warnings.push(`Preview event definition #${index + 1} has a topic that is not domain/subject/verb.`);
523
+ }
524
+ });
525
+ }
526
+ const parsedActions = task.script ? parseActions(task.script, {}, []) : [];
527
+ parsedActions.forEach((action) => {
528
+ if (action.type && !ALLOWED_ACTIONS.has(action.type)) {
529
+ warnings.push(`Action "${action.type}" is not recognized as a standard Mechanic action.`);
530
+ }
531
+ });
532
+ // Check for some risky patterns in preview detection
533
+ if (task.script?.includes('event.preview == false') || task.script?.includes('event.preview === false')) {
534
+ warnings.push('Avoid `event.preview == false`; use `event.preview` to detect preview, and `event.preview != true` to detect live.');
535
+ }
536
+ if (task.script?.includes('shopify')) {
537
+ warnings.push('Shopify calls are not executed in the JS simulator; provide stub data if needed.');
538
+ }
539
+ if (task.script?.includes('cache')) {
540
+ warnings.push('Cache lookups are not available in the simulator.');
541
+ }
542
+ if (task.script) {
543
+ const tagNames = parseTagNames(task.script);
544
+ tagNames.forEach((tag) => {
545
+ if (!BASE_LIQUID_TAGS.has(tag) && !MECHANIC_TAGS.has(tag)) {
546
+ warnings.push(`Tag "${tag}" is not in the known Liquid/Mechanic allowlist (may still be valid in full engine).`);
547
+ }
548
+ });
549
+ const filterNames = parseFilterNames(task.script);
550
+ filterNames.forEach((filter) => {
551
+ if (!BASE_LIQUID_FILTERS.has(filter) && !MECHANIC_FILTERS.has(filter)) {
552
+ warnings.push(`Filter "${filter}" is not in the known Liquid/Mechanic allowlist (may still be valid in full engine).`);
553
+ }
554
+ });
555
+ }
556
+ notes.push('Simulator runs offline with a minimal Liquid subset and static action capture.');
557
+ return lintTaskOutputSchema.parse({ errors, warnings, notes });
558
+ }
559
+ export function previewTask(task, context) {
560
+ const warnings = [...PREVIEW_WARNINGS];
561
+ const errors = [];
562
+ const notes = [];
563
+ if (!task.script) {
564
+ errors.push('No script provided to render.');
565
+ }
566
+ const resolvedOptions = task.options || {};
567
+ const topics = [];
568
+ if (task.preview_event_definitions && task.preview_event_definitions.length > 0) {
569
+ task.preview_event_definitions.forEach((definition) => {
570
+ const topic = definition.event_attributes?.topic;
571
+ if (topic) {
572
+ topics.push(topic);
573
+ }
574
+ });
575
+ }
576
+ else if (task.subscriptions && task.subscriptions.length > 0) {
577
+ topics.push(...task.subscriptions.map((t) => normalizeTopic(t)));
578
+ }
579
+ else if (task.subscriptions_template) {
580
+ // best effort: include static lines without Liquid; otherwise warn and fall back
581
+ const staticLines = task.subscriptions_template
582
+ .split('\n')
583
+ .map((line) => line.trim())
584
+ .filter((line) => line.length > 0 && !line.includes('{') && !line.includes('%'));
585
+ if (staticLines.length > 0) {
586
+ topics.push(...staticLines.map((t) => normalizeTopic(t)));
587
+ }
588
+ else {
589
+ warnings.push('subscriptions_template includes Liquid; sandbox did not evaluate it. Defaulting to fallback preview topic.');
590
+ }
591
+ }
592
+ if (topics.length === 0) {
593
+ topics.push(DEFAULT_CUSTOM_TOPIC);
594
+ }
595
+ const events = topics.map((topic, idx) => {
596
+ const definition = task.preview_event_definitions?.[idx];
597
+ const data = definition?.event_attributes?.data ||
598
+ resolvePayload(topic, context.payload?.data);
599
+ const evt = { topic, data, preview: true };
600
+ const renderContext = {
601
+ event: evt,
602
+ options: resolvedOptions,
603
+ task: {
604
+ id: task.id || 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
605
+ name: task.name || '(preview)',
606
+ tags: task.tags || [],
607
+ },
608
+ };
609
+ const actions = task.script ? parseActions(task.script, renderContext, warnings) : [];
610
+ const logs = task.script ? parseLogs(task.script, renderContext, warnings) : [];
611
+ return { event: evt, actions, logs };
612
+ });
613
+ if (events.length > 1) {
614
+ notes.push(`Multiple preview events detected (${events.length}); showing combined actions/logs, first event shown in response.`);
615
+ }
616
+ const combinedActions = events.flatMap((e) => e.actions);
617
+ const combinedLogs = events.flatMap((e) => e.logs);
618
+ if (combinedActions.length === 0) {
619
+ warnings.push('No actions detected in script. Conditional logic is not evaluated; ensure actions are guarded by event.preview only if intended.');
620
+ }
621
+ // Select first event for schema compatibility; include combined actions/logs
622
+ const primaryEvent = events[0]?.event || { topic: topics[0], data: {}, preview: true };
623
+ return previewTaskOutputSchema.parse({
624
+ event: primaryEvent,
625
+ actions: combinedActions,
626
+ logs: combinedLogs,
627
+ warnings,
628
+ errors,
629
+ notes,
630
+ });
631
+ }
@@ -0,0 +1,32 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { lintTask, previewTask, resolveTask } from '../task-dev.js';
3
+ async function run() {
4
+ const inlineTask = {
5
+ name: 'Preview smoke',
6
+ subscriptions: ['user/test'],
7
+ script: '{% action "echo" %}{{ options.message | json }}{% endaction %}',
8
+ options: { message: 'hi there' },
9
+ };
10
+ const resolved = resolveTask({ task: inlineTask }, { records: [] });
11
+ const lint = lintTask(resolved);
12
+ assert.equal(lint.errors.length, 0, 'Lint should not surface errors');
13
+ assert(lint.warnings.length >= 1, 'Lint should surface simulator warnings');
14
+ const preview = previewTask(resolved, { topic: 'user/test', payload: { data: { foo: 'bar' } } });
15
+ assert.equal(preview.event.topic, 'user/test', 'Preview should use provided topic');
16
+ assert.equal(preview.event.data.foo, 'bar', 'Preview should include provided payload data');
17
+ assert(preview.actions.length === 1, 'Preview should capture one action');
18
+ assert(preview.actions[0].rendered.includes('hi there'), 'Rendered action should include option value');
19
+ assert.equal(preview.errors.length, 0, 'Preview should not surface errors');
20
+ const fixturePreview = previewTask(resolveTask({
21
+ task: {
22
+ subscriptions: ['user/event/preview'],
23
+ script: '{% log event.data.message %}',
24
+ },
25
+ }, { records: [] }), {});
26
+ assert(fixturePreview.logs.some((log) => log.includes('Hello from a preview event')), 'Fixture should populate preview data');
27
+ }
28
+ run().catch((error) => {
29
+ // eslint-disable-next-line no-console
30
+ console.error(error);
31
+ process.exitCode = 1;
32
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightward/mechanic-mcp",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Mechanic MCP server for docs and task library search",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",