mcp-server-sfmc 1.9.2 → 2.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.
package/dist/index.js CHANGED
@@ -16,12 +16,26 @@ import { z } from 'zod';
16
16
  import { sfmcLanguageService, validateAmpscript, validateSsjs, validateGtlBlocks, isMcnSupported, getMcnApiVersion, getMcnNotes, extractAmpscriptFunctionCalls, } from 'sfmc-language-lsp';
17
17
  import { getChunks, getMceHelpStats, searchMceHelp, } from './mce-help-search.js';
18
18
  import { getMcnChunks, getMcnHelpStats, searchMcnHelp } from './mcn-help-search.js';
19
- import { CLOUDPAGES_ONLY_FUNCTIONS, NON_MIGRATABLE_SSJS_PATTERNS, ssjsToAmpscript, ampscriptToSsjs, rewriteAmpForMcn, isSsjsBlockConvertible, } from './conversion-rules.js';
19
+ import { CLOUDPAGES_ONLY_FUNCTIONS, NON_MIGRATABLE_SSJS_PATTERNS, ssjsToAmpscript, ampscriptToSsjs, rewriteAmpForMcn, isSsjsBlockConvertible, ampscriptToHandlebars, handlebarsToAmpscript, ssjsToHandlebars, AMP_MCN_HANDLEBARS_GAP, HBS_GAP_NOTE, } from './conversion-rules.js';
20
20
  import { ECMASCRIPT_BUILTINS, KNOWN_UNSUPPORTED, polyfillByPrototypeName, polyfillByStaticName, } from 'ssjs-data';
21
21
  function projectPackageRoot() {
22
22
  return path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
23
23
  }
24
- const pkg = JSON.parse(fs.readFileSync(path.join(projectPackageRoot(), 'package.json'), 'utf8'));
24
+ const packageJsonPath = path.join(projectPackageRoot(), 'package.json');
25
+ const package_ = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
26
+ /**
27
+ * Normalize a completion item's `label` (which may be a string or a
28
+ * `{ label: string }` object) to its plain-string form.
29
+ * @param item - A completion item with a string or object label.
30
+ * @param item.label
31
+ * @returns {string} The label text.
32
+ */
33
+ function labelText(item) {
34
+ if (typeof item.label === 'string') {
35
+ return item.label;
36
+ }
37
+ return item.label.label;
38
+ }
25
39
  // ---------------------------------------------------------------------------
26
40
  // Server instance
27
41
  // ---------------------------------------------------------------------------
@@ -84,7 +98,7 @@ const SERVER_INSTRUCTIONS = 'This server provides authoritative SFMC language in
84
98
  'and route the query to the right doc index (or both for MCN). Only fall back to the individual ' +
85
99
  'search tools when you need to scope by `product_focus` (MCE only) or when you are **certain** ' +
86
100
  'which doc index to target.';
87
- const server = new McpServer({ name: 'mcp-server-sfmc', version: pkg.version }, { instructions: SERVER_INSTRUCTIONS });
101
+ const server = new McpServer({ name: 'mcp-server-sfmc', version: package_.version }, { instructions: SERVER_INSTRUCTIONS });
88
102
  function detectLanguage(code, hint) {
89
103
  if (hint === 'ssjs')
90
104
  return 'ssjs';
@@ -106,6 +120,17 @@ function detectLanguage(code, hint) {
106
120
  return 'ssjs';
107
121
  return 'ampscript';
108
122
  }
123
+ /**
124
+ * Extracts the human-readable text from a diagnostic message, which may be a
125
+ * plain string or a `{ value }` wrapper depending on the language-service type.
126
+ * @param message
127
+ */
128
+ function diagnosticMessage(message) {
129
+ if (typeof message === 'string') {
130
+ return message;
131
+ }
132
+ return message.value;
133
+ }
109
134
  function formatDiagnostics(diagnostics) {
110
135
  if (diagnostics.length === 0)
111
136
  return 'No issues found.';
@@ -113,11 +138,33 @@ function formatDiagnostics(diagnostics) {
113
138
  .map((d) => {
114
139
  const sev = d.severity === 1 ? 'ERROR' : d.severity === 2 ? 'WARNING' : 'INFO';
115
140
  const loc = `line ${d.range.start.line + 1}, col ${d.range.start.character + 1}`;
116
- const message = typeof d.message === 'string' ? d.message : d.message.value;
141
+ const message = diagnosticMessage(d.message);
117
142
  return `[${sev}] ${loc}: ${message}`;
118
143
  })
119
144
  .join('\n');
120
145
  }
146
+ function formatHandlebarsHelper(helper) {
147
+ const parameters = helper.params
148
+ .map((p) => {
149
+ const request = p.optional ? '(optional)' : '(required)';
150
+ const variadic = p.variadic ? ' (variadic)' : '';
151
+ return ` - ${p.name}: ${p.type}${variadic} ${request}${p.description ? ' — ' + p.description : ''}`;
152
+ })
153
+ .join('\n');
154
+ const subexpr = helper.subexpressionOnly
155
+ ? '\n\n> Subexpression-only — may only be used inside `( … )`.'
156
+ : '';
157
+ return (`## {{${helper.name}}}\n\n` +
158
+ `**Category:** ${helper.category}\n\n` +
159
+ `**Origin:** ${helper.origin}\n\n` +
160
+ `**Type:** ${helper.helperType}\n\n` +
161
+ `**Returns:** ${helper.returnType}\n\n` +
162
+ `**Description:** ${helper.description}\n\n` +
163
+ `**Parameters:**\n${parameters || ' (none)'}` +
164
+ subexpr +
165
+ `\n\n✅ **Marketing Cloud Next:** Supported since API v${helper.mcnSince}.0` +
166
+ (helper.docUrl ? `\n\n[Documentation](${helper.docUrl})` : ''));
167
+ }
121
168
  // ---------------------------------------------------------------------------
122
169
  // Tool: validate_ampscript
123
170
  // ---------------------------------------------------------------------------
@@ -215,28 +262,28 @@ server.tool('lookup_ampscript_function', 'Look up the signature, parameters, des
215
262
  'Case-insensitive. Returns null if the function is not found.', {
216
263
  name: z.string().describe('The AMPscript function name, e.g. "Lookup", "DateAdd", "IIf".'),
217
264
  }, ({ name }) => {
218
- const fn = sfmcLanguageService.lookupAmpscriptFunction(name);
219
- if (!fn) {
265
+ const function_ = sfmcLanguageService.lookupAmpscriptFunction(name);
266
+ if (!function_) {
220
267
  return { content: [{ type: 'text', text: `AMPscript function "${name}" not found.` }] };
221
268
  }
222
- const params = fn.params
269
+ const parameters = function_.params
223
270
  .map((p) => {
224
- const req = p.optional ? '(optional)' : '(required)';
225
- return ` - ${p.name}: ${p.type ?? 'any'} ${req}${p.description ? ' — ' + p.description : ''}`;
271
+ const request = p.optional ? '(optional)' : '(required)';
272
+ return ` - ${p.name}: ${p.type ?? 'any'} ${request}${p.description ? ' — ' + p.description : ''}`;
226
273
  })
227
274
  .join('\n');
228
- const examples = fn.example ? '\n\nExample:\n' + fn.example : '';
275
+ const examples = function_.example ? '\n\nExample:\n' + function_.example : '';
229
276
  // MCN compatibility badge
230
- const fnMcnSince = fn.mcnSince ?? null;
231
- const fnMcnNotes = fn.mcnNotes ?? null;
232
- const mcnLine = fnMcnSince === null
277
+ const functionMcnSince = function_.mcnSince ?? null;
278
+ const functionMcnNotes = function_.mcnNotes ?? null;
279
+ const mcnLine = functionMcnSince === null
233
280
  ? '\n\n❌ **Marketing Cloud Next:** Not supported'
234
- : `\n\n✅ **Marketing Cloud Next:** Supported since API v${fnMcnSince}.0` +
235
- (fnMcnNotes ? `\n> **MCN Note:** ${fnMcnNotes}` : '');
236
- const text = `## ${fn.name}\n\n` +
237
- `**Category:** ${fn.category ?? 'Unknown'}\n\n` +
238
- `**Description:** ${fn.description ?? ''}\n\n` +
239
- `**Parameters:**\n${params || ' (none)'}` +
281
+ : `\n\n✅ **Marketing Cloud Next:** Supported since API v${functionMcnSince}.0` +
282
+ (functionMcnNotes ? `\n> **MCN Note:** ${functionMcnNotes}` : '');
283
+ const text = `## ${function_.name}\n\n` +
284
+ `**Category:** ${function_.category ?? 'Unknown'}\n\n` +
285
+ `**Description:** ${function_.description ?? ''}\n\n` +
286
+ `**Parameters:**\n${parameters || ' (none)'}` +
240
287
  examples +
241
288
  mcnLine;
242
289
  return { content: [{ type: 'text', text }] };
@@ -289,6 +336,90 @@ server.tool('list_ampscript_functions', 'List all AMPscript functions, optionall
289
336
  };
290
337
  });
291
338
  // ---------------------------------------------------------------------------
339
+ // Tool: validate_handlebars
340
+ // ---------------------------------------------------------------------------
341
+ server.tool('validate_handlebars', 'Validate Marketing Cloud Next (MCN) Handlebars template code. ' +
342
+ 'Checks helper names, arity, block balance, and flags unsupported constructs ' +
343
+ '(partials, decorators, and built-in helpers absent from the locked-down MCN engine). ' +
344
+ 'MCN Handlebars lives inside the combined sfmc language and is always validated for the ' +
345
+ "'next' target.", {
346
+ code: z
347
+ .string()
348
+ .describe('The MCN Handlebars template code to validate. May include HTML context.'),
349
+ maxProblems: z
350
+ .number()
351
+ .int()
352
+ .min(1)
353
+ .max(500)
354
+ .optional()
355
+ .describe('Maximum number of problems to return (default 100).'),
356
+ }, ({ code, maxProblems }) => {
357
+ const settings = {
358
+ maxNumberOfProblems: maxProblems ?? 100,
359
+ targetPlatform: 'next',
360
+ };
361
+ const diagnostics = validateAmpscript(code, settings).filter((d) => d.source === 'handlebars');
362
+ return {
363
+ content: [{ type: 'text', text: formatDiagnostics(diagnostics) }],
364
+ };
365
+ });
366
+ // ---------------------------------------------------------------------------
367
+ // Tool: lookup_handlebars_helper
368
+ // ---------------------------------------------------------------------------
369
+ server.tool('lookup_handlebars_helper', 'Look up the signature, parameters, description, and origin for a Marketing Cloud Next ' +
370
+ 'Handlebars helper by name. Case-insensitive. Returns null if the helper is not found.', {
371
+ name: z
372
+ .string()
373
+ .describe('The Handlebars helper name, e.g. "uppercase", "if", "formatDate".'),
374
+ }, ({ name }) => {
375
+ const helper = sfmcLanguageService.lookupHandlebarsHelper(name);
376
+ if (!helper) {
377
+ return {
378
+ content: [{ type: 'text', text: `Handlebars helper "${name}" not found.` }],
379
+ };
380
+ }
381
+ return { content: [{ type: 'text', text: formatHandlebarsHelper(helper) }] };
382
+ });
383
+ // ---------------------------------------------------------------------------
384
+ // Tool: list_handlebars_helpers
385
+ // ---------------------------------------------------------------------------
386
+ server.tool('list_handlebars_helpers', 'List all Marketing Cloud Next Handlebars helpers, optionally filtered by category ' +
387
+ 'and/or origin (handlebars-builtin, mcn-helper, mcn-platform).', {
388
+ category: z
389
+ .string()
390
+ .optional()
391
+ .describe('Filter by helper category (case-insensitive substring match), e.g. "string", "date", "comparison".'),
392
+ origin: z
393
+ .enum(['handlebars-builtin', 'mcn-helper', 'mcn-platform'])
394
+ .optional()
395
+ .describe('Filter by helper origin.'),
396
+ }, ({ category, origin }) => {
397
+ let helpers = sfmcLanguageService.listHandlebarsHelpers();
398
+ if (origin) {
399
+ helpers = helpers.filter((h) => h.origin === origin);
400
+ }
401
+ if (category) {
402
+ const catLower = category.toLowerCase();
403
+ helpers = helpers.filter((h) => h.category.toLowerCase().includes(catLower));
404
+ }
405
+ if (helpers.length === 0) {
406
+ return {
407
+ content: [{ type: 'text', text: 'No Handlebars helpers match the filter.' }],
408
+ };
409
+ }
410
+ const rows = helpers
411
+ .map((h) => `- **${h.name}** *(${h.category}, ${h.origin}, ${h.helperType})*: ${h.description}`)
412
+ .join('\n');
413
+ return {
414
+ content: [
415
+ {
416
+ type: 'text',
417
+ text: `## MCN Handlebars Helpers\n\n${rows}`,
418
+ },
419
+ ],
420
+ };
421
+ });
422
+ // ---------------------------------------------------------------------------
292
423
  // ECMAScript-builtin support lookup, sourced exclusively from ssjs-data.
293
424
  // Used as a fall-through inside lookup_ssjs_function so a single tool answers
294
425
  // "can I use X in SSJS?" for both SFMC APIs and plain-JavaScript built-ins.
@@ -422,35 +553,37 @@ server.tool('lookup_ssjs_function', 'Authoritative lookup (sourced ONLY from the
422
553
  }, ({ name, owner }) => {
423
554
  // Strip namespace prefix for lookup
424
555
  const bare = name.replace(/^(Platform\.(Function|Variable|Response|Request|ClientBrowser|Recipient|DateTime)\.|WSProxy\.|HTTP\.|Script\.Util\.|Function\.|Variable\.|Response\.|Request\.)/i, '');
425
- const fn = sfmcLanguageService.lookupSsjsFunction(bare);
426
- if (!fn) {
556
+ const function_ = sfmcLanguageService.lookupSsjsFunction(bare);
557
+ if (!function_) {
427
558
  // Fall through to the ECMAScript built-in / polyfill / unsupported catalogs.
428
559
  const builtinResult = lookupSsjsBuiltin(name, owner);
429
560
  return { content: [{ type: 'text', text: builtinResult.text }] };
430
561
  }
431
- const params = (fn.params ?? [])
562
+ const parameters = (function_.params ?? [])
432
563
  .map((p) => {
433
564
  const isOptional = p.optional || p.required === false;
434
- const req = isOptional ? '(optional)' : '(required)';
435
- return ` - ${p.name}: ${p.type ?? 'any'} ${req}${p.description ? ' — ' + p.description : ''}`;
565
+ const request = isOptional ? '(optional)' : '(required)';
566
+ return ` - ${p.name}: ${p.type ?? 'any'} ${request}${p.description ? ' — ' + p.description : ''}`;
436
567
  })
437
568
  .join('\n');
438
569
  const badges = [];
439
- if (fn.deprecated) {
570
+ if (function_.deprecated) {
440
571
  badges.push('⚠️ **Deprecated** — avoid in new code.');
441
572
  }
442
- if (fn.requiresCoreLoad) {
573
+ if (function_.requiresCoreLoad) {
443
574
  badges.push('⚠️ **Requires** `Platform.Load("core", "1.1.5")` before calling this method.');
444
575
  }
445
- const header = fn.isStatic ? `## ${fn.name} *(static)*` : `## ${fn.name}`;
576
+ const header = function_.isStatic
577
+ ? `## ${function_.name} *(static)*`
578
+ : `## ${function_.name}`;
446
579
  const badgeBlock = badges.length > 0 ? badges.join('\n') + '\n\n' : '';
447
- const aliasLine = fn.aliasOf ? `**Alias of:** \`${fn.aliasOf}\`\n\n` : '';
580
+ const aliasLine = function_.aliasOf ? `**Alias of:** \`${function_.aliasOf}\`\n\n` : '';
448
581
  const text = `${header}\n\n` +
449
582
  badgeBlock +
450
583
  aliasLine +
451
- `**Description:** ${fn.description ?? ''}\n\n` +
452
- `**Parameters:**\n${params || ' (none)'}\n\n` +
453
- `**Returns:** ${fn.returnType ?? 'void'}`;
584
+ `**Description:** ${function_.description ?? ''}\n\n` +
585
+ `**Parameters:**\n${parameters || ' (none)'}\n\n` +
586
+ `**Returns:** ${function_.returnType ?? 'void'}`;
454
587
  return { content: [{ type: 'text', text }] };
455
588
  });
456
589
  // ---------------------------------------------------------------------------
@@ -474,13 +607,13 @@ server.tool('review_change', 'Review a code diff for SFMC (AMPscript, SSJS, or H
474
607
  }, ({ diff, language = 'auto', maxProblems = 50 }) => {
475
608
  // Extract added lines from the unified diff
476
609
  const addedLines = [];
477
- let lineNum = 0;
610
+ let lineNumber = 0;
478
611
  const lineMap = []; // maps index in addedLines to original diff line number
479
612
  for (const line of diff.split('\n')) {
480
- lineNum++;
613
+ lineNumber++;
481
614
  if (line.startsWith('+') && !line.startsWith('+++')) {
482
615
  addedLines.push(line.slice(1));
483
- lineMap.push(lineNum);
616
+ lineMap.push(lineNumber);
484
617
  }
485
618
  }
486
619
  if (addedLines.length === 0) {
@@ -493,8 +626,8 @@ server.tool('review_change', 'Review a code diff for SFMC (AMPscript, SSJS, or H
493
626
  ? detectLanguage(addedCode, 'html')
494
627
  : language;
495
628
  const settings = { maxNumberOfProblems: maxProblems };
496
- const doc = { text: addedCode, languageId: detectedLang, uri: 'diff' };
497
- const diagnostics = sfmcLanguageService.validate(doc, settings);
629
+ const document = { text: addedCode, languageId: detectedLang, uri: 'diff' };
630
+ const diagnostics = sfmcLanguageService.validate(document, settings);
498
631
  if (diagnostics.length === 0) {
499
632
  return {
500
633
  content: [
@@ -509,7 +642,7 @@ server.tool('review_change', 'Review a code diff for SFMC (AMPscript, SSJS, or H
509
642
  for (const d of diagnostics) {
510
643
  const sev = d.severity === 1 ? '🔴 ERROR' : d.severity === 2 ? '🟡 WARNING' : '🔵 INFO';
511
644
  const origLine = lineMap[d.range.start.line] ?? d.range.start.line + 1;
512
- const message = typeof d.message === 'string' ? d.message : d.message.value;
645
+ const message = diagnosticMessage(d.message);
513
646
  output.push(`${sev} (diff line ${origLine}): ${message}`);
514
647
  }
515
648
  return { content: [{ type: 'text', text: output.join('\n') }] };
@@ -538,15 +671,15 @@ server.tool('suggest_fix', 'Generate a corrected version of SFMC code based on v
538
671
  ? detectLanguage(code)
539
672
  : detectLanguage(code, language);
540
673
  const settings = { maxNumberOfProblems: 50, targetPlatform: target };
541
- const doc = { text: code, languageId: detectedLang, uri: 'fix-target' };
542
- const diagnostics = sfmcLanguageService.validate(doc, settings);
674
+ const document = { text: code, languageId: detectedLang, uri: 'fix-target' };
675
+ const diagnostics = sfmcLanguageService.validate(document, settings);
543
676
  const lines = code.split('\n');
544
677
  const suggestions = [];
545
678
  for (const d of diagnostics) {
546
679
  const lineText = lines[d.range.start.line] ?? '';
547
680
  // Diagnostic.message widened to `string | MarkupContent` in newer LSP types; the
548
681
  // SFMC language service only emits plain strings, so unwrap MarkupContent to its value.
549
- const message = typeof d.message === 'string' ? d.message : d.message.value;
682
+ const message = diagnosticMessage(d.message);
550
683
  suggestions.push(`Line ${d.range.start.line + 1}: ${message}\n` +
551
684
  ` Code: ${lineText.trim()}\n` +
552
685
  ` Fix: ${getFixSuggestion(message, lineText, detectedLang)}`);
@@ -568,6 +701,24 @@ server.tool('suggest_fix', 'Generate a corrected version of SFMC code based on v
568
701
  */
569
702
  function getFixSuggestion(message, line, lang) {
570
703
  const m = message.toLowerCase();
704
+ // MCN Handlebars diagnostics (surfaced when target === 'next' on {{…}} regions).
705
+ if (m.includes('partials ({{>'))
706
+ return 'Inline the partial content directly — the locked-down MCN engine cannot register partials.';
707
+ if (m.includes('partial blocks'))
708
+ return 'Inline the block content directly — the MCN engine cannot register partial blocks.';
709
+ if (m.includes('decorators ({{*') || m.includes('decorator blocks'))
710
+ return 'Remove the decorator — the MCN engine cannot register decorators.';
711
+ if (m.includes('{{log}}'))
712
+ return 'Remove the {{log}} debugging helper — it is not available in MCN Handlebars.';
713
+ if (m.includes('unknown handlebars helper')) {
714
+ const helperMatch = message.match(/'([^']+)'/);
715
+ const hint = helperMatch
716
+ ? ` Check spelling or use a catalog helper instead of "${helperMatch[1]}".`
717
+ : '';
718
+ return `Use a helper from the MCN Handlebars catalog (list_handlebars_helpers); custom helpers cannot be registered.${hint}`;
719
+ }
720
+ if (m.includes('handlebars for marketing cloud next') || m.includes('mcn engine'))
721
+ return 'Replace with a supported MCN Handlebars construct — see list_handlebars_helpers.';
571
722
  if (m.includes("'let'") || m.includes("'const'"))
572
723
  return 'Replace `let`/`const` with `var`.';
573
724
  if (m.includes('arrow function'))
@@ -583,9 +734,9 @@ function getFixSuggestion(message, line, lang) {
583
734
  if (m.includes('html comment'))
584
735
  return 'Remove the `<!-- -->` wrapper; use `/* comment */` inside AMPscript.';
585
736
  if (m.includes('unknown function')) {
586
- const fnMatch = message.match(/"([^"]+)"/);
587
- if (fnMatch)
588
- return `Check spelling — did you mean a known AMPscript function? ("${fnMatch[1]}")`;
737
+ const functionMatch = message.match(/"([^"]+)"/);
738
+ if (functionMatch)
739
+ return `Check spelling — did you mean a known AMPscript function? ("${functionMatch[1]}")`;
589
740
  }
590
741
  if (m.includes('expects'))
591
742
  return 'Check the number and types of arguments against the function signature.';
@@ -606,13 +757,11 @@ server.tool('get_ampscript_completions', 'Return a list of AMPscript function na
606
757
  .optional()
607
758
  .describe("Target platform. Use 'next' to return only completions supported in Marketing Cloud Next."),
608
759
  }, ({ code, line, character, target }) => {
609
- const doc = { text: code, languageId: 'ampscript', uri: 'completions' };
610
- let items = sfmcLanguageService.getCompletions(doc, { line, character });
760
+ const document = { text: code, languageId: 'ampscript', uri: 'completions' };
761
+ let items = sfmcLanguageService.getCompletions(document, { line, character });
611
762
  if (target === 'next') {
612
763
  items = items.filter((item) => {
613
- const label = typeof item.label === 'string'
614
- ? item.label
615
- : item.label.label;
764
+ const label = labelText(item);
616
765
  // Keep keywords and variables (non-function entries); filter out MCN-unsupported functions
617
766
  return !label.includes('(') || isMcnSupported(label.replace(/\(.*/, '').trim());
618
767
  });
@@ -620,9 +769,7 @@ server.tool('get_ampscript_completions', 'Return a list of AMPscript function na
620
769
  const formatted = items
621
770
  .slice(0, 50)
622
771
  .map((item) => {
623
- const label = typeof item.label === 'string'
624
- ? item.label
625
- : item.label.label;
772
+ const label = labelText(item);
626
773
  return `- ${label}${item.detail ? ` — ${item.detail}` : ''}`;
627
774
  })
628
775
  .join('\n');
@@ -665,19 +812,12 @@ server.tool('get_ssjs_completions', 'Return a list of SSJS Platform functions, W
665
812
  }
666
813
  const items = sfmcLanguageService.getSsjsCompletionCatalog();
667
814
  const filtered = filter
668
- ? items.filter((item) => {
669
- const label = typeof item.label === 'string'
670
- ? item.label
671
- : item.label.label;
672
- return label.toLowerCase().startsWith(filter.toLowerCase());
673
- })
815
+ ? items.filter((item) => labelText(item).toLowerCase().startsWith(filter.toLowerCase()))
674
816
  : items;
675
817
  const formatted = filtered
676
818
  .slice(0, 80)
677
819
  .map((item) => {
678
- const label = typeof item.label === 'string'
679
- ? item.label
680
- : item.label.label;
820
+ const label = labelText(item);
681
821
  return `- ${label}${item.detail ? ` — ${item.detail}` : ''}`;
682
822
  })
683
823
  .join('\n');
@@ -693,12 +833,74 @@ server.tool('get_ssjs_completions', 'Return a list of SSJS Platform functions, W
693
833
  };
694
834
  });
695
835
  // ---------------------------------------------------------------------------
836
+ // Tool: get_handlebars_completions
837
+ // ---------------------------------------------------------------------------
838
+ server.tool('get_handlebars_completions', 'Return Marketing Cloud Next (MCN) Handlebars helper completions with snippet insert text. ' +
839
+ 'MCN Handlebars is only available on the next platform, so this catalog is always the ' +
840
+ '\'next\' set. Optionally filter by a name prefix (e.g. "form", "date").', {
841
+ filter: z
842
+ .string()
843
+ .optional()
844
+ .describe('Optional helper-name prefix filter (case-insensitive), e.g. "form", "to".'),
845
+ }, ({ filter }) => {
846
+ const items = sfmcLanguageService.getHandlebarsCompletionCatalog();
847
+ const filtered = filter
848
+ ? items.filter((item) => labelText(item).toLowerCase().startsWith(filter.toLowerCase()))
849
+ : items;
850
+ const formatted = filtered
851
+ .slice(0, 80)
852
+ .map((item) => {
853
+ const label = labelText(item);
854
+ return `- ${label}${item.detail ? ` — ${item.detail}` : ''}`;
855
+ })
856
+ .join('\n');
857
+ return {
858
+ content: [
859
+ {
860
+ type: 'text',
861
+ text: filtered.length === 0
862
+ ? `No MCN Handlebars helpers matching "${filter}".`
863
+ : `${filtered.length} MCN Handlebars helper completions${filter ? ` matching "${filter}"` : ''} (showing up to 80):\n\n${formatted}`,
864
+ },
865
+ ],
866
+ };
867
+ });
868
+ // ---------------------------------------------------------------------------
696
869
  // Tool: format_sfmc_code (basic, no prettier integration needed)
697
870
  // ---------------------------------------------------------------------------
698
- server.tool('format_sfmc_code', 'Apply basic formatting conventions to AMPscript or SSJS code. ' +
699
- 'Normalises keyword casing, whitespace around operators, and indentation hints.', {
871
+ /**
872
+ * Conservatively normalize whitespace inside Marketing Cloud Next (MCN)
873
+ * Handlebars `{{…}}` mustaches: collapse runs of internal whitespace to a
874
+ * single space and trim the edges (e.g. `{{ foo bar }}` → `{{foo bar}}`),
875
+ * matching what Prettier's Glimmer parser does for mustache interiors.
876
+ *
877
+ * Hard guardrails (never altered):
878
+ * - `{!$…}` merge-field bindings — they are not Handlebars syntax.
879
+ * - `{{!-- … --}}` / `{{! … }}` comments — preserved byte-for-byte.
880
+ *
881
+ * This is the deferred-Prettier stub for item 8 of the MCN Handlebars handoff:
882
+ * full routing through prettier-plugin-sfmc's Glimmer path lands once that
883
+ * plugin ships Handlebars support (see HANDOFF-prettier-handlebars.md).
884
+ * @param {string} code - The Handlebars-in-HTML code to normalize.
885
+ * @returns {string} The code with mustache interiors normalized.
886
+ */
887
+ function normalizeHandlebarsWhitespace(code) {
888
+ return code.replaceAll(/\{\{([\s\S]*?)\}\}/g, (full, inner) => {
889
+ // Preserve comments verbatim.
890
+ if (inner.startsWith('!')) {
891
+ return full;
892
+ }
893
+ const normalized = inner.replaceAll(/\s+/g, ' ').trim();
894
+ return `{{${normalized}}}`;
895
+ });
896
+ }
897
+ server.tool('format_sfmc_code', 'Apply basic formatting conventions to AMPscript, SSJS, or Marketing Cloud Next (MCN) ' +
898
+ 'Handlebars code. Normalises keyword casing and whitespace. For Handlebars, collapses ' +
899
+ 'whitespace inside {{…}} mustaches while leaving {!$…} bindings and {{!-- … --}} comments ' +
900
+ 'untouched. (Handlebars formatting is a conservative whitespace normalizer; full Prettier ' +
901
+ 'Glimmer routing is deferred until prettier-plugin-sfmc ships Handlebars support.)', {
700
902
  code: z.string().describe('The SFMC code to format.'),
701
- language: z.enum(['ampscript', 'ssjs']).describe('The language of the code.'),
903
+ language: z.enum(['ampscript', 'ssjs', 'handlebars']).describe('The language of the code.'),
702
904
  }, ({ code, language }) => {
703
905
  let formatted = code;
704
906
  if (language === 'ampscript') {
@@ -709,6 +911,9 @@ server.tool('format_sfmc_code', 'Apply basic formatting conventions to AMPscript
709
911
  .replaceAll(/\bOR\b/gi, 'OR')
710
912
  .replaceAll(/\bNOT\b/gi, 'NOT');
711
913
  }
914
+ else if (language === 'handlebars') {
915
+ formatted = normalizeHandlebarsWhitespace(formatted);
916
+ }
712
917
  else {
713
918
  // SSJS: normalise Platform.Load to use double quotes
714
919
  formatted = formatted.replaceAll(/Platform\.Load\s*\(\s*'([^']*)'\s*,\s*'([^']*)'\s*\)/g, 'Platform.Load("$1", "$2")');
@@ -759,9 +964,9 @@ server.tool('search_mce_help', MCE_HELP_TOOL_DESC, {
759
964
  : `No matches for this query with product_focus="${focus}". Try broader keywords or product_focus="any".`;
760
965
  return { content: [{ type: 'text', text: hint }] };
761
966
  }
762
- const lines = hits.map((h, i) => {
967
+ const lines = hits.map((h, index) => {
763
968
  const excerpt = h.chunk.body.replaceAll(/\s+/g, ' ').slice(0, 520);
764
- return (`### ${i + 1}. ${h.chunk.relativePath} — ${h.chunk.heading}\n` +
969
+ return (`### ${index + 1}. ${h.chunk.relativePath} — ${h.chunk.heading}\n` +
765
970
  `**Product:** ${h.chunk.productLabel}\n` +
766
971
  `**Score:** ${h.score}\n\n` +
767
972
  `${excerpt}${h.chunk.body.length > 520 ? '…' : ''}\n`);
@@ -775,11 +980,11 @@ server.tool('search_mce_help', MCE_HELP_TOOL_DESC, {
775
980
  // ---------------------------------------------------------------------------
776
981
  server.resource('ampscript-function-catalog', 'sfmc://ampscript/functions', async () => {
777
982
  const functions = sfmcLanguageService.getAllAmpscriptFunctions();
778
- const lines = functions.map((fn) => {
779
- const paramList = fn.params
983
+ const lines = functions.map((function_) => {
984
+ const parameterList = function_.params
780
985
  .map((p) => p.optional ? `[${p.name}: ${p.type ?? 'any'}]` : `${p.name}: ${p.type ?? 'any'}`)
781
986
  .join(', ');
782
- return `${fn.name}(${paramList}) — ${fn.description ?? ''}`;
987
+ return `${function_.name}(${parameterList}) — ${function_.description ?? ''}`;
783
988
  });
784
989
  return {
785
990
  contents: [
@@ -797,13 +1002,13 @@ server.resource('ampscript-function-catalog', 'sfmc://ampscript/functions', asyn
797
1002
  // ---------------------------------------------------------------------------
798
1003
  server.resource('ssjs-function-catalog', 'sfmc://ssjs/functions', async () => {
799
1004
  const functions = sfmcLanguageService.getAllSsjsFunctions();
800
- const lines = functions.map((fn) => {
801
- const paramList = (fn.params ?? [])
1005
+ const lines = functions.map((function_) => {
1006
+ const parameterList = (function_.params ?? [])
802
1007
  .map((p) => p.optional || p.required === false
803
1008
  ? `[${p.name}: ${p.type ?? 'any'}]`
804
1009
  : `${p.name}: ${p.type ?? 'any'}`)
805
1010
  .join(', ');
806
- return `${fn.name}(${paramList}) — ${fn.description ?? ''}`;
1011
+ return `${function_.name}(${parameterList}) — ${function_.description ?? ''}`;
807
1012
  });
808
1013
  return {
809
1014
  contents: [
@@ -817,6 +1022,48 @@ server.resource('ssjs-function-catalog', 'sfmc://ssjs/functions', async () => {
817
1022
  };
818
1023
  });
819
1024
  // ---------------------------------------------------------------------------
1025
+ // Resource: handlebars-helper-catalog
1026
+ // ---------------------------------------------------------------------------
1027
+ server.resource('handlebars-helper-catalog', 'sfmc://handlebars/helpers', async () => {
1028
+ const helpers = sfmcLanguageService.listHandlebarsHelpers();
1029
+ const lines = helpers.map((h) => {
1030
+ const parameterList = h.params
1031
+ .map((p) => {
1032
+ const inner = `${p.name}: ${p.type}`;
1033
+ return p.optional ? `[${inner}]` : inner;
1034
+ })
1035
+ .join(', ');
1036
+ return `{{${h.name} ${parameterList}}} — (${h.category}, ${h.origin}, v${h.mcnSince}.0+) ${h.description}`;
1037
+ });
1038
+ return {
1039
+ contents: [
1040
+ {
1041
+ uri: 'sfmc://handlebars/helpers',
1042
+ mimeType: 'text/plain',
1043
+ text: `# MCN Handlebars Helper Catalog (${helpers.length} helpers)\n\n` +
1044
+ lines.join('\n'),
1045
+ },
1046
+ ],
1047
+ };
1048
+ });
1049
+ // ---------------------------------------------------------------------------
1050
+ // Resource: handlebars-binding-catalog
1051
+ // ---------------------------------------------------------------------------
1052
+ server.resource('handlebars-binding-catalog', 'sfmc://handlebars/bindings', async () => {
1053
+ const bindings = sfmcLanguageService.listHandlebarsBindings();
1054
+ const lines = bindings.map((b) => `${b.token} — (${b.namespace}, v${b.mcnSince}.0+) ${b.description}`);
1055
+ return {
1056
+ contents: [
1057
+ {
1058
+ uri: 'sfmc://handlebars/bindings',
1059
+ mimeType: 'text/plain',
1060
+ text: `# MCN Handlebars Built-in Binding Catalog (${bindings.length} bindings)\n\n` +
1061
+ lines.join('\n'),
1062
+ },
1063
+ ],
1064
+ };
1065
+ });
1066
+ // ---------------------------------------------------------------------------
820
1067
  // Resource: ampscript-keywords
821
1068
  // ---------------------------------------------------------------------------
822
1069
  server.resource('ampscript-keywords', 'sfmc://ampscript/keywords', async () => {
@@ -902,7 +1149,7 @@ server.resource('mce-product-context', 'sfmc://mce/product-context', async () =>
902
1149
  // ---------------------------------------------------------------------------
903
1150
  server.resource('mce-help-index', 'sfmc://mce/help-index', async () => {
904
1151
  const chunks = getChunks();
905
- const files = [...new Set(chunks.map((c) => c.relativePath))].sort();
1152
+ const files = [...new Set(chunks.map((c) => c.relativePath))].sort((a, b) => a.localeCompare(b));
906
1153
  const stats = getMceHelpStats();
907
1154
  const scopeRows = Object.entries(stats.breakdown)
908
1155
  .sort(([, a], [, b]) => b - a)
@@ -921,7 +1168,7 @@ server.resource('mce-help-index', 'sfmc://mce/help-index', async () => {
921
1168
  // ---------------------------------------------------------------------------
922
1169
  server.resource('mcn-help-index', 'sfmc://mcn/help-index', async () => {
923
1170
  const chunks = getMcnChunks();
924
- const files = [...new Set(chunks.map((c) => c.relativePath))].sort();
1171
+ const files = [...new Set(chunks.map((c) => c.relativePath))].sort((a, b) => a.localeCompare(b));
925
1172
  const stats = getMcnHelpStats();
926
1173
  const text = `# Bundled Marketing Cloud Next developer API docs (${stats.chunkCount} sections from ${stats.fileCount} files)\n\n` +
927
1174
  `## Files\n\n` +
@@ -1032,16 +1279,16 @@ server.tool('search_help', 'Unified help search that automatically detects the t
1032
1279
  const sections = [];
1033
1280
  if (platform === 'next') {
1034
1281
  // MCN: search both the developer API reference and the MCN operational/admin help
1035
- const devHits = searchMcnHelp(query, Math.ceil(limit / 2));
1282
+ const developmentHits = searchMcnHelp(query, Math.ceil(limit / 2));
1036
1283
  const opsHits = searchMceHelp(query, Math.floor(limit / 2), 'next');
1037
- if (devHits.length > 0) {
1038
- const parts = devHits.map(({ chunk }) => `### ${chunk.heading}\n*Source: ${chunk.relativePath}*\n\n${chunk.body}`);
1284
+ if (developmentHits.length > 0) {
1285
+ const parts = developmentHits.map(({ chunk }) => `### ${chunk.heading}\n*Source: ${chunk.relativePath}*\n\n${chunk.body}`);
1039
1286
  sections.push(`## MCN Developer API Docs\n\n${parts.join('\n\n---\n\n')}`);
1040
1287
  }
1041
1288
  if (opsHits.length > 0) {
1042
- const parts = opsHits.map((h, i) => {
1289
+ const parts = opsHits.map((h, index) => {
1043
1290
  const excerpt = h.chunk.body.replaceAll(/\s+/g, ' ').slice(0, 520);
1044
- return (`### ${i + 1}. ${h.chunk.relativePath} — ${h.chunk.heading}\n` +
1291
+ return (`### ${index + 1}. ${h.chunk.relativePath} — ${h.chunk.heading}\n` +
1045
1292
  `**Score:** ${h.score}\n\n` +
1046
1293
  `${excerpt}${h.chunk.body.length > 520 ? '…' : ''}`);
1047
1294
  });
@@ -1071,9 +1318,9 @@ server.tool('search_help', 'Unified help search that automatically detects the t
1071
1318
  : `No results found for "${query}". Try broader keywords.`;
1072
1319
  return { content: [{ type: 'text', text: hint }] };
1073
1320
  }
1074
- const parts = hits.map((h, i) => {
1321
+ const parts = hits.map((h, index) => {
1075
1322
  const excerpt = h.chunk.body.replaceAll(/\s+/g, ' ').slice(0, 520);
1076
- return (`### ${i + 1}. ${h.chunk.relativePath} — ${h.chunk.heading}\n` +
1323
+ return (`### ${index + 1}. ${h.chunk.relativePath} — ${h.chunk.heading}\n` +
1077
1324
  `**Product:** ${h.chunk.productLabel}\n` +
1078
1325
  `**Score:** ${h.score}\n\n` +
1079
1326
  `${excerpt}${h.chunk.body.length > 520 ? '…' : ''}`);
@@ -1244,6 +1491,23 @@ server.prompt('reviewSfmcCode', 'Review SFMC code for correctness, best practice
1244
1491
  const platformNote = target === 'next'
1245
1492
  ? '\n- **Marketing Cloud Next compatibility**: Flag any AMPscript functions not supported in MCN (API v67.0+), and any SSJS blocks (SSJS is not supported in MCN).'
1246
1493
  : '';
1494
+ const fenceLang = detectedLang === 'ssjs' ? 'javascript' : detectedLang;
1495
+ const checklistItems = detectedLang === 'ampscript'
1496
+ ? [
1497
+ '- Delimiter balance (%%[ ]%%, %%= =%%)',
1498
+ '- IF/ENDIF, FOR/NEXT block balance',
1499
+ '- Correct function names and argument counts',
1500
+ '- Correct comment syntax (/* */ only)',
1501
+ '- Proper variable declaration with @',
1502
+ ]
1503
+ : [
1504
+ '- No ES6+ syntax (var, not let/const; no arrow functions)',
1505
+ '- Platform.Load before Core library objects',
1506
+ '- Correct Platform.Function calls',
1507
+ '- WSProxy error handling',
1508
+ '- No sensitive data in logs or responses',
1509
+ ];
1510
+ const checklist = checklistItems.join('\n') + platformNote;
1247
1511
  return {
1248
1512
  messages: [
1249
1513
  {
@@ -1260,26 +1524,12 @@ server.prompt('reviewSfmcCode', 'Review SFMC code for correctness, best practice
1260
1524
  focus ? `Focus especially on: ${focus}` : '',
1261
1525
  '',
1262
1526
  '## Code to Review',
1263
- '```' + (detectedLang === 'ssjs' ? 'javascript' : detectedLang),
1527
+ '```' + fenceLang,
1264
1528
  code,
1265
1529
  '```',
1266
1530
  '',
1267
1531
  '## Review checklist',
1268
- detectedLang === 'ampscript'
1269
- ? [
1270
- '- Delimiter balance (%%[ ]%%, %%= =%%)',
1271
- '- IF/ENDIF, FOR/NEXT block balance',
1272
- '- Correct function names and argument counts',
1273
- '- Correct comment syntax (/* */ only)',
1274
- '- Proper variable declaration with @',
1275
- ].join('\n') + platformNote
1276
- : [
1277
- '- No ES6+ syntax (var, not let/const; no arrow functions)',
1278
- '- Platform.Load before Core library objects',
1279
- '- Correct Platform.Function calls',
1280
- '- WSProxy error handling',
1281
- '- No sensitive data in logs or responses',
1282
- ].join('\n') + platformNote,
1532
+ checklist,
1283
1533
  ]
1284
1534
  .filter(Boolean)
1285
1535
  .join('\n'),
@@ -1395,16 +1645,56 @@ server.prompt('answerMceHowTo', 'Answer a Marketing Cloud **administration or se
1395
1645
  // ---------------------------------------------------------------------------
1396
1646
  // Tool: check_mcn_compatibility
1397
1647
  // ---------------------------------------------------------------------------
1648
+ const mcnFileObjectSchema = z.object({
1649
+ filename: z.string().describe('File name (e.g. "email-template.html").'),
1650
+ content: z.string().describe('Full file content to analyze.'),
1651
+ });
1652
+ const mcnFileArraySchema = z.array(mcnFileObjectSchema).describe('List of files to analyze.');
1653
+ /**
1654
+ * Returns the reason for the first non-migratable SSJS pattern that matches the
1655
+ * given block code, or an empty string when none match.
1656
+ * @param blockCode
1657
+ */
1658
+ function firstNonMigratableSsjsReason(blockCode) {
1659
+ for (const { pattern, reason } of NON_MIGRATABLE_SSJS_PATTERNS) {
1660
+ pattern.lastIndex = 0;
1661
+ if (pattern.test(blockCode)) {
1662
+ return reason;
1663
+ }
1664
+ }
1665
+ return '';
1666
+ }
1667
+ /**
1668
+ * Scans content for recognized Handlebars helper usages, deduplicated by
1669
+ * helper name and approximate line. Matching is data-driven via the LSP lookup.
1670
+ * @param content
1671
+ */
1672
+ function collectHandlebarsHelperUsages(content) {
1673
+ const helperCallPattern = /\{\{[#/]?\s*([a-zA-Z][\w-]*)/g;
1674
+ const seenHelper = new Set();
1675
+ const usages = [];
1676
+ let helperMatch;
1677
+ while ((helperMatch = helperCallPattern.exec(content)) !== null) {
1678
+ const token = helperMatch[1];
1679
+ const helper = sfmcLanguageService.lookupHandlebarsHelper(token);
1680
+ if (!helper) {
1681
+ continue;
1682
+ }
1683
+ const line = content.slice(0, helperMatch.index).split('\n').length;
1684
+ const dedupeKey = `${helper.name}@${line}`;
1685
+ if (seenHelper.has(dedupeKey)) {
1686
+ continue;
1687
+ }
1688
+ seenHelper.add(dedupeKey);
1689
+ usages.push({ name: helper.name, line, mcnSince: helper.mcnSince });
1690
+ }
1691
+ return usages;
1692
+ }
1398
1693
  server.tool('check_mcn_compatibility', 'Analyze one or more AMPscript/HTML files for Marketing Cloud Next (MCN) readiness. ' +
1399
1694
  'Returns an executive summary and a per-file, per-function report with migration difficulty. ' +
1400
1695
  'SSJS blocks that only use Platform.Function.* calls are classified as "Needs conversion" (not "Not migratable"). ' +
1401
1696
  'Use this tool before using rewrite_for_mcn.', {
1402
- files: z
1403
- .array(z.object({
1404
- filename: z.string().describe('File name (e.g. "email-template.html").'),
1405
- content: z.string().describe('Full file content to analyze.'),
1406
- }))
1407
- .describe('List of files to analyze.'),
1697
+ files: mcnFileArraySchema,
1408
1698
  }, ({ files }) => {
1409
1699
  const results = [];
1410
1700
  for (const { filename, content } of files) {
@@ -1417,7 +1707,15 @@ server.tool('check_mcn_compatibility', 'Analyze one or more AMPscript/HTML files
1417
1707
  const notes = getMcnNotes(site.name);
1418
1708
  let status;
1419
1709
  let reason;
1420
- if (mcnSince !== null && notes === null) {
1710
+ const isHbsGap = AMP_MCN_HANDLEBARS_GAP.has(site.name.toLowerCase());
1711
+ if (isHbsGap) {
1712
+ // Category C: documented as MCN-supported but no working Handlebars
1713
+ // helper exists yet — fails at runtime. Always needs-review, even when
1714
+ // mcnSince is set, because the data flags a runtime gap.
1715
+ status = 'needs-review';
1716
+ reason = `${site.name}() is ${HBS_GAP_NOTE}`;
1717
+ }
1718
+ else if (mcnSince !== null && notes === null) {
1421
1719
  status = 'supported';
1422
1720
  reason = '—';
1423
1721
  }
@@ -1444,14 +1742,7 @@ server.tool('check_mcn_compatibility', 'Analyze one or more AMPscript/HTML files
1444
1742
  const blockCode = blockMatch[1];
1445
1743
  const lineApprox = content.slice(0, blockMatch.index).split('\n').length;
1446
1744
  // Check for non-migratable patterns
1447
- let notMigratableReason = '';
1448
- for (const { pattern, reason } of NON_MIGRATABLE_SSJS_PATTERNS) {
1449
- pattern.lastIndex = 0;
1450
- if (pattern.test(blockCode)) {
1451
- notMigratableReason = reason;
1452
- break;
1453
- }
1454
- }
1745
+ const notMigratableReason = firstNonMigratableSsjsReason(blockCode);
1455
1746
  if (notMigratableReason) {
1456
1747
  ssjsBlocks.push({
1457
1748
  index: blockIndex,
@@ -1469,28 +1760,61 @@ server.tool('check_mcn_compatibility', 'Analyze one or more AMPscript/HTML files
1469
1760
  });
1470
1761
  }
1471
1762
  }
1763
+ // 2b. Analyze MCN Handlebars regions (only meaningful for the 'next' target).
1764
+ // Diagnostics flag unsupported constructs (partials, decorators, built-in
1765
+ // helpers absent from the locked-down engine), unknown helpers, and arity.
1766
+ const handlebarsProblems = [];
1767
+ const handlebarsHelpers = [];
1768
+ if (content.includes('{{')) {
1769
+ const hbsDiagnostics = validateAmpscript(content, {
1770
+ maxNumberOfProblems: 200,
1771
+ targetPlatform: 'next',
1772
+ }).filter((d) => d.source === 'handlebars');
1773
+ for (const d of hbsDiagnostics) {
1774
+ const severity = d.severity === 1 ? 'error' : d.severity === 2 ? 'warning' : 'info';
1775
+ const message = diagnosticMessage(d.message);
1776
+ handlebarsProblems.push({
1777
+ line: d.range.start.line + 1,
1778
+ severity,
1779
+ message,
1780
+ });
1781
+ }
1782
+ // Report recognized helper usages with their MCN availability version.
1783
+ // Helper names are matched against handlebars-data via the LSP lookup so
1784
+ // this stays data-driven (no hand-maintained helper list).
1785
+ handlebarsHelpers.push(...collectHandlebarsHelperUsages(content));
1786
+ }
1472
1787
  // 3. Assess per-file difficulty
1473
- const hasCloudPagesFn = ampFunctions.some((f) => CLOUDPAGES_ONLY_FUNCTIONS.has(f.name.toLowerCase()) &&
1788
+ const hasCloudPagesFunction = ampFunctions.some((f) => CLOUDPAGES_ONLY_FUNCTIONS.has(f.name.toLowerCase()) &&
1474
1789
  f.status === 'not-supported');
1475
1790
  const hasNotMigratableSsjs = ssjsBlocks.some((b) => b.status === 'not-migratable');
1476
1791
  const hasUnsupportedAmp = ampFunctions.some((f) => f.status === 'not-supported' &&
1477
1792
  !CLOUDPAGES_ONLY_FUNCTIONS.has(f.name.toLowerCase()));
1478
1793
  const hasConvertibleSsjs = ssjsBlocks.some((b) => b.status === 'needs-conversion');
1479
1794
  const hasNeedsReview = ampFunctions.some((f) => f.status === 'needs-review');
1795
+ const hasHandlebarsError = handlebarsProblems.some((p) => p.severity === 'error');
1796
+ const hasHandlebarsWarning = handlebarsProblems.some((p) => p.severity === 'warning');
1480
1797
  let difficulty;
1481
- if (hasCloudPagesFn || hasNotMigratableSsjs) {
1798
+ if (hasCloudPagesFunction || hasNotMigratableSsjs) {
1482
1799
  difficulty = 'not-migratable';
1483
1800
  }
1484
- else if (hasUnsupportedAmp || hasConvertibleSsjs) {
1801
+ else if (hasUnsupportedAmp || hasConvertibleSsjs || hasHandlebarsError) {
1485
1802
  difficulty = 'significant';
1486
1803
  }
1487
- else if (hasNeedsReview) {
1804
+ else if (hasNeedsReview || hasHandlebarsWarning) {
1488
1805
  difficulty = 'minor';
1489
1806
  }
1490
1807
  else {
1491
1808
  difficulty = 'ready';
1492
1809
  }
1493
- results.push({ filename, difficulty, ampFunctions, ssjsBlocks });
1810
+ results.push({
1811
+ filename,
1812
+ difficulty,
1813
+ ampFunctions,
1814
+ ssjsBlocks,
1815
+ handlebarsProblems,
1816
+ handlebarsHelpers,
1817
+ });
1494
1818
  }
1495
1819
  // 4. Build Markdown report
1496
1820
  const difficultyLabel = {
@@ -1541,22 +1865,37 @@ server.tool('check_mcn_compatibility', 'Analyze one or more AMPscript/HTML files
1541
1865
  }
1542
1866
  if (result.ampFunctions.length > 0) {
1543
1867
  fileLines.push('| Function | Line | Status | Reason |', '|---|---|---|---|');
1544
- for (const fn of result.ampFunctions) {
1545
- const icon = fn.status === 'supported'
1868
+ for (const function_ of result.ampFunctions) {
1869
+ const icon = function_.status === 'supported'
1546
1870
  ? '✅'
1547
- : fn.status === 'needs-review'
1871
+ : function_.status === 'needs-review'
1548
1872
  ? '⚠️'
1549
1873
  : '❌';
1550
- const statusLabel = fn.status === 'supported'
1874
+ const statusLabel = function_.status === 'supported'
1551
1875
  ? 'Supported'
1552
- : fn.status === 'needs-review'
1876
+ : function_.status === 'needs-review'
1553
1877
  ? 'Needs review'
1554
1878
  : 'Not supported';
1555
- fileLines.push(`| ${fn.name} | ${fn.line} | ${icon} ${statusLabel} | ${fn.reason} |`);
1879
+ fileLines.push(`| ${function_.name} | ${function_.line} | ${icon} ${statusLabel} | ${function_.reason} |`);
1556
1880
  }
1557
1881
  }
1558
- else if (result.ssjsBlocks.length === 0) {
1559
- fileLines.push('*No AMPscript functions or SSJS blocks found.*');
1882
+ else if (result.ssjsBlocks.length === 0 &&
1883
+ result.handlebarsProblems.length === 0 &&
1884
+ result.handlebarsHelpers.length === 0) {
1885
+ fileLines.push('*No AMPscript functions, SSJS blocks, or Handlebars found.*');
1886
+ }
1887
+ if (result.handlebarsHelpers.length > 0) {
1888
+ fileLines.push('', '**MCN Handlebars Helpers:**', '');
1889
+ for (const h of result.handlebarsHelpers) {
1890
+ fileLines.push(`✅ \`{{${h.name}}}\` (line ${h.line}): Supported since API v${h.mcnSince}.0`);
1891
+ }
1892
+ }
1893
+ if (result.handlebarsProblems.length > 0) {
1894
+ fileLines.push('', '**MCN Handlebars Problems:**', '');
1895
+ for (const p of result.handlebarsProblems) {
1896
+ const icon = p.severity === 'error' ? '❌' : p.severity === 'warning' ? '⚠️' : 'ℹ️';
1897
+ fileLines.push(`${icon} Line ${p.line}: ${p.message}`);
1898
+ }
1560
1899
  }
1561
1900
  fileLines.push('', '---');
1562
1901
  }
@@ -1641,7 +1980,8 @@ server.tool('rewrite_for_mcn', 'Deterministically rewrite AMPscript (and optiona
1641
1980
  });
1642
1981
  // Reassess difficulty
1643
1982
  const hasMigratable = allNonMigratable.length > 0;
1644
- const difficulty = hasMigratable && allNonMigratable.some((i) => i.reason.includes('not-migratable'))
1983
+ const difficulty = hasMigratable &&
1984
+ allNonMigratable.some((index) => index.reason.includes('not-migratable'))
1645
1985
  ? 'not-migratable'
1646
1986
  : ampResult.difficulty;
1647
1987
  const result = {
@@ -1801,6 +2141,263 @@ server.tool('convertAmpscriptToSsjs', 'Deterministically convert AMPscript code
1801
2141
  };
1802
2142
  });
1803
2143
  // ---------------------------------------------------------------------------
2144
+ // Tool: convertAmpscriptToHandlebars
2145
+ // ---------------------------------------------------------------------------
2146
+ server.tool('convertAmpscriptToHandlebars', 'Deterministically convert AMPscript to Marketing Cloud Next (MCN) Handlebars using the ' +
2147
+ 'three-category model built from ampscript-data: (A) functions with a Handlebars helper ' +
2148
+ 'equivalent become {{helper …}}; (B) functions with no MCN counterpart become a ' +
2149
+ 'MANUAL_REWRITE_REQUIRED comment; (C) mcnHandlebarsGap functions (e.g. ContentBlockByKey) ' +
2150
+ 'become a DISTINCT MANUAL_REWRITE_REQUIRED comment noting they are documented as ' +
2151
+ 'MCN-supported but currently fail at runtime. Procedural AMPscript blocks (SET/VAR/IF/FOR) ' +
2152
+ 'have no Handlebars equivalent and are flagged. ' +
2153
+ 'Use the convertAmpscriptToHandlebars PROMPT for AI-enhanced handling of flagged sections.', {
2154
+ code: z.string().describe('The AMPscript code to convert to MCN Handlebars.'),
2155
+ }, ({ code }) => {
2156
+ const result = ampscriptToHandlebars(code);
2157
+ return {
2158
+ content: [
2159
+ {
2160
+ type: 'text',
2161
+ text: JSON.stringify({
2162
+ convertedCode: result.convertedCode,
2163
+ changes: result.changes,
2164
+ flaggedSections: result.flaggedSections,
2165
+ }, null, 2),
2166
+ },
2167
+ ],
2168
+ };
2169
+ });
2170
+ // ---------------------------------------------------------------------------
2171
+ // Prompt: convertAmpscriptToHandlebars
2172
+ // ---------------------------------------------------------------------------
2173
+ server.prompt('convertAmpscriptToHandlebars', 'Convert AMPscript to Marketing Cloud Next (MCN) Handlebars. ' +
2174
+ 'Calls the convertAmpscriptToHandlebars tool first for a deterministic, data-driven ' +
2175
+ 'conversion, then applies AI reasoning to handle MANUAL_REWRITE_REQUIRED sections.', {
2176
+ code: z.string().describe('The AMPscript code to convert to MCN Handlebars.'),
2177
+ }, ({ code }) => ({
2178
+ messages: [
2179
+ {
2180
+ role: 'user',
2181
+ content: {
2182
+ type: 'text',
2183
+ text: [
2184
+ 'You are an expert Salesforce Marketing Cloud developer converting AMPscript to Marketing Cloud Next (MCN) Handlebars.',
2185
+ '',
2186
+ '## Instructions',
2187
+ '1. Call the `convertAmpscriptToHandlebars` **tool** with the code below to get a deterministic conversion.',
2188
+ '2. Review the `flaggedSections` — these are constructs the tool could not convert automatically.',
2189
+ '3. For each flagged section, apply your expertise, but obey these hard rules:',
2190
+ ' - NEVER invent a Handlebars helper. Only use helpers that exist in the MCN catalog (call `list_handlebars_helpers` / `lookup_handlebars_helper`).',
2191
+ ' - Category B (no MCN counterpart): leave a clear `{{!-- MANUAL_REWRITE_REQUIRED … --}}` note explaining the manual step.',
2192
+ ' - Category C (documented-supported but runtime gap, e.g. ContentBlockByKey): keep the DISTINCT runtime-gap note — do not replace it with a fabricated helper.',
2193
+ ' - AMPscript procedural blocks (SET/VAR/IF/FOR) have no Handlebars equivalent — Handlebars cannot assign variables or run imperative logic. Restructure the data upstream instead.',
2194
+ '4. Validate your final output by calling `validate_handlebars`.',
2195
+ '5. Produce a single final Handlebars-in-HTML code block plus a short bulleted change log.',
2196
+ '',
2197
+ '## AMPscript code to convert',
2198
+ '```',
2199
+ code,
2200
+ '```',
2201
+ ].join('\n'),
2202
+ },
2203
+ },
2204
+ ],
2205
+ }));
2206
+ // ---------------------------------------------------------------------------
2207
+ // Tool: convertHandlebarsToAmpscript
2208
+ // ---------------------------------------------------------------------------
2209
+ server.tool('convertHandlebarsToAmpscript', 'Deterministically convert Marketing Cloud Next (MCN) Handlebars to AMPscript (best-effort, ' +
2210
+ 'lossy). Inline helper calls that map back to an AMPscript function become %%=Fn(…)=%%; ' +
2211
+ 'bare variables {{name}} become %%=v(@name)=%%. Block helpers ({{#each}}/{{#if}}), partials, ' +
2212
+ 'dotted binding paths, and helpers with no AMPscript equivalent are flagged ' +
2213
+ 'MANUAL_REWRITE_REQUIRED. Use the convertHandlebarsToAmpscript PROMPT for AI-enhanced handling.', {
2214
+ code: z.string().describe('The MCN Handlebars code to convert to AMPscript.'),
2215
+ }, ({ code }) => {
2216
+ const result = handlebarsToAmpscript(code);
2217
+ return {
2218
+ content: [
2219
+ {
2220
+ type: 'text',
2221
+ text: JSON.stringify({
2222
+ convertedCode: result.convertedCode,
2223
+ changes: result.changes,
2224
+ flaggedSections: result.flaggedSections,
2225
+ }, null, 2),
2226
+ },
2227
+ ],
2228
+ };
2229
+ });
2230
+ // ---------------------------------------------------------------------------
2231
+ // Prompt: convertHandlebarsToAmpscript
2232
+ // ---------------------------------------------------------------------------
2233
+ server.prompt('convertHandlebarsToAmpscript', 'Convert Marketing Cloud Next (MCN) Handlebars to AMPscript (best-effort, lossy). ' +
2234
+ 'Calls the convertHandlebarsToAmpscript tool first for a deterministic conversion, then ' +
2235
+ 'applies AI reasoning to handle MANUAL_REWRITE_REQUIRED sections.', {
2236
+ code: z.string().describe('The MCN Handlebars code to convert to AMPscript.'),
2237
+ }, ({ code }) => ({
2238
+ messages: [
2239
+ {
2240
+ role: 'user',
2241
+ content: {
2242
+ type: 'text',
2243
+ text: [
2244
+ 'You are an expert Salesforce Marketing Cloud developer converting MCN Handlebars to AMPscript.',
2245
+ '',
2246
+ '## Instructions',
2247
+ '1. Call the `convertHandlebarsToAmpscript` **tool** with the code below to get a deterministic conversion.',
2248
+ '2. Review the `flaggedSections` — block helpers, partials, and binding paths often need context-specific AMPscript.',
2249
+ '3. For each flagged section, apply your expertise:',
2250
+ ' - `{{#each items}}…{{/each}}` → AMPscript `FOR @i = 1 TO RowCount(@items) DO … NEXT @i`',
2251
+ ' - `{{#if cond}}…{{else}}…{{/if}}` → `IF cond THEN … ELSE … ENDIF`',
2252
+ ' - `{!$…}` bindings and `mcn-platform` helpers may have no AMPscript equivalent — keep the MANUAL_REWRITE_REQUIRED note.',
2253
+ '4. Produce a single final AMPscript code block plus a short bulleted change log.',
2254
+ '',
2255
+ '## Handlebars code to convert',
2256
+ '```',
2257
+ code,
2258
+ '```',
2259
+ ].join('\n'),
2260
+ },
2261
+ },
2262
+ ],
2263
+ }));
2264
+ // ---------------------------------------------------------------------------
2265
+ // Tool: convertSsjsToHandlebars
2266
+ // ---------------------------------------------------------------------------
2267
+ server.tool('convertSsjsToHandlebars', 'Deterministically convert SSJS to Marketing Cloud Next (MCN) Handlebars transitively ' +
2268
+ '(SSJS → AMPscript → Handlebars). Because Handlebars is declarative, most imperative SSJS ' +
2269
+ 'has no Handlebars counterpart and is conservatively flagged MANUAL_REWRITE_REQUIRED; ' +
2270
+ 'inline Platform.Function.* calls that map through AMPscript to a Handlebars helper are ' +
2271
+ 'converted. Use the convertSsjsToHandlebars PROMPT for AI-enhanced handling of flagged sections.', {
2272
+ code: z
2273
+ .string()
2274
+ .describe('The SSJS code to convert (may include <script runat="server"> tags).'),
2275
+ }, ({ code }) => {
2276
+ const result = ssjsToHandlebars(code);
2277
+ return {
2278
+ content: [
2279
+ {
2280
+ type: 'text',
2281
+ text: JSON.stringify({
2282
+ convertedCode: result.convertedCode,
2283
+ changes: result.changes,
2284
+ flaggedSections: result.flaggedSections,
2285
+ }, null, 2),
2286
+ },
2287
+ ],
2288
+ };
2289
+ });
2290
+ // ---------------------------------------------------------------------------
2291
+ // Prompt: convertSsjsToHandlebars
2292
+ // ---------------------------------------------------------------------------
2293
+ server.prompt('convertSsjsToHandlebars', 'Convert SSJS to Marketing Cloud Next (MCN) Handlebars. Calls the convertSsjsToHandlebars ' +
2294
+ 'tool first (SSJS → AMPscript → Handlebars), then applies AI reasoning to handle the many ' +
2295
+ 'MANUAL_REWRITE_REQUIRED sections that imperative SSJS produces.', {
2296
+ code: z.string().describe('The SSJS code to convert to MCN Handlebars.'),
2297
+ }, ({ code }) => ({
2298
+ messages: [
2299
+ {
2300
+ role: 'user',
2301
+ content: {
2302
+ type: 'text',
2303
+ text: [
2304
+ 'You are an expert Salesforce Marketing Cloud developer converting SSJS to Marketing Cloud Next (MCN) Handlebars.',
2305
+ '',
2306
+ '## Key reality',
2307
+ 'MCN runs a locked-down Handlebars engine. SSJS does NOT exist in MCN, and Handlebars is declarative — it cannot run imperative logic. Conversion is only possible transitively (SSJS → MCN-valid AMPscript subset → Handlebars), and most non-trivial SSJS will need a redesign (move logic to the data layer / a query).',
2308
+ '',
2309
+ '## Instructions',
2310
+ '1. Call the `convertSsjsToHandlebars` **tool** with the code below to get a deterministic conversion.',
2311
+ '2. Review the `flaggedSections` from BOTH stages ([SSJS→AMPscript] and [AMPscript→Handlebars]).',
2312
+ '3. For each flagged section, apply your expertise — but NEVER invent a Handlebars helper (use only the MCN catalog via `list_handlebars_helpers`).',
2313
+ '4. Where imperative logic cannot be expressed, recommend moving it upstream (e.g. a `{{#query}}` / data binding) and keep a clear MANUAL_REWRITE_REQUIRED note.',
2314
+ '5. Validate your final output by calling `validate_handlebars`.',
2315
+ '6. Produce a single final Handlebars-in-HTML code block plus a short bulleted change log.',
2316
+ '',
2317
+ '## SSJS code to convert',
2318
+ '```javascript',
2319
+ code,
2320
+ '```',
2321
+ ].join('\n'),
2322
+ },
2323
+ },
2324
+ ],
2325
+ }));
2326
+ // ---------------------------------------------------------------------------
2327
+ // Tool: write_handlebars
2328
+ // ---------------------------------------------------------------------------
2329
+ server.tool('write_handlebars', 'Validate a Marketing Cloud Next (MCN) Handlebars-in-HTML draft so it can be finalized as ' +
2330
+ 'authored content. Runs the MCN Handlebars validator (locked-down engine) on the draft and ' +
2331
+ 'returns whether it is clean, plus any diagnostics (unknown/too-new helpers, unsupported ' +
2332
+ 'constructs, arity). Use the write_handlebars PROMPT to AUTHOR from intent — that prompt ' +
2333
+ 'instructs the model to use only catalog helpers and then call this tool to verify.', {
2334
+ draft: z.string().describe('The Handlebars-in-HTML draft to validate before finalizing.'),
2335
+ intent: z
2336
+ .string()
2337
+ .optional()
2338
+ .describe('Optional human description of what the content should do (echoed for context).'),
2339
+ }, ({ draft, intent }) => {
2340
+ const diagnostics = validateAmpscript(draft, {
2341
+ maxNumberOfProblems: 100,
2342
+ targetPlatform: 'next',
2343
+ }).filter((d) => d.source === 'handlebars');
2344
+ const isClean = diagnostics.length === 0;
2345
+ const header = isClean
2346
+ ? '✅ Draft is valid MCN Handlebars.'
2347
+ : `❌ Draft has ${diagnostics.length} MCN Handlebars issue(s) — fix before finalizing:`;
2348
+ const intentLine = intent ? `Intent: ${intent}\n\n` : '';
2349
+ return {
2350
+ content: [
2351
+ {
2352
+ type: 'text',
2353
+ text: `${intentLine}${header}\n\n${formatDiagnostics(diagnostics)}`,
2354
+ },
2355
+ ],
2356
+ };
2357
+ });
2358
+ // ---------------------------------------------------------------------------
2359
+ // Prompt: writeHandlebars
2360
+ // ---------------------------------------------------------------------------
2361
+ server.prompt('writeHandlebars', 'Author Marketing Cloud Next (MCN) Handlebars-in-HTML from a natural-language intent, using ' +
2362
+ 'only helpers that exist in the MCN catalog, then validate the result with write_handlebars.', {
2363
+ intent: z
2364
+ .string()
2365
+ .describe('What the content should do, e.g. "greet the subscriber by first name and show their loyalty tier".'),
2366
+ context: z
2367
+ .string()
2368
+ .optional()
2369
+ .describe('Optional data/personalization context (available fields, bindings, sample data).'),
2370
+ }, ({ intent, context }) => ({
2371
+ messages: [
2372
+ {
2373
+ role: 'user',
2374
+ content: {
2375
+ type: 'text',
2376
+ text: [
2377
+ 'You are an expert Salesforce Marketing Cloud developer authoring Marketing Cloud Next (MCN) Handlebars-in-HTML.',
2378
+ '',
2379
+ '## Hard rules',
2380
+ '- MCN runs a LOCKED-DOWN Handlebars engine. NEVER invent a helper. Only use helpers in the MCN catalog — call `list_handlebars_helpers` (and `lookup_handlebars_helper` for signatures) before writing.',
2381
+ '- Respect each helper`s `mcnSince` availability.',
2382
+ '- Partials ({{> …}}), decorators ({{* …}}), and built-in helpers absent from MCN (e.g. {{log}}) are NOT allowed.',
2383
+ '- `{!$…}` bindings are merge fields, not helpers — use `list_handlebars_helpers` output / bindings catalog for valid ones.',
2384
+ '',
2385
+ '## Instructions',
2386
+ '1. Inspect the available catalog with `list_handlebars_helpers`.',
2387
+ '2. Author the Handlebars-in-HTML to satisfy the intent below.',
2388
+ '3. Call the `write_handlebars` **tool** with your draft to validate it.',
2389
+ '4. If validation reports issues, fix them and re-validate until clean.',
2390
+ '5. Return the final, validated Handlebars-in-HTML code block.',
2391
+ '',
2392
+ `## Intent`,
2393
+ intent,
2394
+ ...(context ? ['', '## Context', context] : []),
2395
+ ].join('\n'),
2396
+ },
2397
+ },
2398
+ ],
2399
+ }));
2400
+ // ---------------------------------------------------------------------------
1804
2401
  // Tool: get_server_version
1805
2402
  // ---------------------------------------------------------------------------
1806
2403
  server.tool('get_server_version', 'Return the running mcp-server-sfmc version and the size of the bundled Salesforce ' +
@@ -1815,7 +2412,7 @@ server.tool('get_server_version', 'Return the running mcp-server-sfmc version an
1815
2412
  type: 'text',
1816
2413
  text: JSON.stringify({
1817
2414
  name: 'mcp-server-sfmc',
1818
- version: pkg.version,
2415
+ version: package_.version,
1819
2416
  mceHelp: { chunkCount: mce.chunkCount, fileCount: mceFileCount },
1820
2417
  mcnHelp: { chunkCount: mcn.chunkCount, fileCount: mcn.fileCount },
1821
2418
  }, null, 2),
@@ -1828,15 +2425,18 @@ server.tool('get_server_version', 'Return the running mcp-server-sfmc version an
1828
2425
  // ---------------------------------------------------------------------------
1829
2426
  async function main() {
1830
2427
  if (process.argv.includes('--version') || process.argv.includes('-v')) {
1831
- process.stdout.write(pkg.version + '\n');
2428
+ process.stdout.write(package_.version + '\n');
1832
2429
  return;
1833
2430
  }
1834
2431
  const transport = new StdioServerTransport();
1835
2432
  await server.connect(transport);
1836
2433
  process.stderr.write('mcp-server-sfmc running on stdio\n');
1837
2434
  }
1838
- main().catch((ex) => {
2435
+ try {
2436
+ await main();
2437
+ }
2438
+ catch (ex) {
1839
2439
  process.stderr.write(`Fatal: ${String(ex)}\n`);
1840
2440
  process.exit(1);
1841
- });
2441
+ }
1842
2442
  //# sourceMappingURL=index.js.map