@enfyra/mcp-server 0.1.11 → 0.1.13

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/README.md CHANGED
@@ -258,7 +258,7 @@ The MCP server includes safety guards for LLM callers:
258
258
  - Write tools require `get_enfyra_required_knowledge` acknowledgement before mutating Enfyra state. Discovery, validation, and preview tools remain available without the acknowledgement so agents can read and plan first. If the acknowledgement is missing, the tool error tells the caller to read `get_enfyra_required_knowledge` and pass the required key.
259
259
  - Script-backed records validate `sourceCode` through `/admin/script/validate` before saving.
260
260
  - `validate_dynamic_script` checks handler, hook, flow, websocket, GraphQL, and bootstrap script source without saving.
261
- - `validate_extension_code` checks Enfyra admin extension code through `/enfyra_extension/preview` without saving.
261
+ - `validate_extension_code` locally rejects common extension component-resolution mistakes, such as `resolveComponent()` or lowercase auto-injected component tags like `<ubutton>`, then checks Enfyra admin extension code through `/enfyra_extension/preview` without saving.
262
262
  - Dynamic script guidance distinguishes secure repositories (`@REPOS.main`, `@REPOS.secure.<table>`) from trusted internal repositories (`@REPOS.<table>`), and tells agents not to return raw trusted records to users.
263
263
  - `compiledCode` is generated from `sourceCode` and may differ textually because macros are expanded; the MCP server never accepts hand-written `compiledCode`.
264
264
  - Long source/code values in read responses are written to `/tmp/enfyra-mcp-sources` and returned as length/hash/preview/tmpFile metadata so LLM callers can inspect full source from the file path without truncating tool output.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "packageManager": "yarn@4.17.0",
@@ -1597,6 +1597,7 @@ ensure_page_extension({
1597
1597
  'Use enfyra_menu.label, not title.',
1598
1598
  'Sensitive admin menus should include a permission condition at creation time.',
1599
1599
  'For page extensions, create the menu first with ensure_menu and pass its id to ensure_page_extension.',
1600
+ 'When editing an existing extension by id or name, use update_extension_code so local guards plus /enfyra_extension/preview and the save happen in one atomic call. Do not spend a second LLM step on validate_extension_code followed by update_record unless the user requested validation-only output.',
1600
1601
  'Call get_extension_theme_contract before writing or reviewing page/widget/global extension UI; that tool is the authority for theme, color, layout, modal, drawer, and shell registry details.',
1601
1602
  'Call get_enfyra_required_knowledge before saving extension code, pass globalRulesAckKey as globalRulesAckKey, and pass extensionAckKey as extensionKnowledgeAckKey.',
1602
1603
  'Page extensions must register the app-shell PageHeader with usePageHeaderRegistry instead of rendering a custom top header.',
@@ -33,7 +33,8 @@ export function buildMcpServerInstructions(apiBaseUrl) {
33
33
  '- Before mutating metadata, schema, routes, permissions, menus, packages, cache state, dynamic code, or extension UI, call `get_enfyra_required_knowledge`, read the global rules, and pass `globalRulesAckKey` into write tools. Dynamic server code also requires `dynamicCodeAckKey`; extension code also requires `extensionAckKey`.',
34
34
  '- With non-root API tokens, call `get_permission_profile` before relying on admin helper tools or when debugging 403s. MCP admin helpers require ordinary route permissions for static admin routes such as `/admin/script/validate`, `/admin/test/run`, `/admin/flow/trigger/:id`, and `/admin/reload/*`.',
35
35
  '- Prefer the most specific business operation tool over raw metadata CRUD. `discover_enfyra_workflows` provides the current operation-tool map and negative-routing avoidTools.',
36
- '- Before saving standalone dynamic script or extension code, call `validate_dynamic_script` or `validate_extension_code` unless the chosen ensure/update tool already validates the code.',
36
+ '- Before saving standalone dynamic script code, call `validate_dynamic_script` unless the chosen write tool already validates the code. For extension edits, prefer `update_extension_code`, `extension_workflow`, or `ensure_*_extension`; these validate and save atomically. Use `validate_extension_code` only for validation-only checks.',
37
+ '- Extension SFCs must use auto-injected components directly in templates, such as `<UButton>`, and must not call `resolveComponent()` for Nuxt UI/eApp components.',
37
38
  '- For existing script-backed records, use `trace_metadata_usage` then `get_script_source`; edit with `patch_script_source` or `update_script_source` so source is hash-checked and validated.',
38
39
  '- Validate behavior with `test_rest_endpoint`, `run_admin_test`, `test_flow_step`, or the route-specific tool before claiming a dynamic feature works.',
39
40
  '',
@@ -12,6 +12,36 @@ import {
12
12
  globalRulesAckParam,
13
13
  } from './required-knowledge.js';
14
14
 
15
+ const AUTO_INJECTED_EXTENSION_COMPONENT_TAGS = [
16
+ 'CommonDrawer',
17
+ 'CommonModal',
18
+ 'EmptyState',
19
+ 'FormEditor',
20
+ 'FormEditorLazy',
21
+ 'NuxtLink',
22
+ 'PermissionGate',
23
+ 'UBadge',
24
+ 'UButton',
25
+ 'UCheckbox',
26
+ 'UDropdownMenu',
27
+ 'UForm',
28
+ 'UFormField',
29
+ 'UIcon',
30
+ 'UInput',
31
+ 'UModal',
32
+ 'USelect',
33
+ 'USelectMenu',
34
+ 'USkeleton',
35
+ 'USwitch',
36
+ 'UTabs',
37
+ 'UTextarea',
38
+ 'UTooltip',
39
+ 'Widget',
40
+ ];
41
+ const AUTO_INJECTED_EXTENSION_COMPONENT_BY_LOWERCASE = new Map(
42
+ AUTO_INJECTED_EXTENSION_COMPONENT_TAGS.map((tag) => [tag.toLowerCase(), tag]),
43
+ );
44
+
15
45
  function unwrapData(result) {
16
46
  return Array.isArray(result?.data) ? result.data : [];
17
47
  }
@@ -360,6 +390,7 @@ function getExtensionThemeContract() {
360
390
  ],
361
391
  components: [
362
392
  'Use Nuxt UI/eApp components for normal controls: UButton, UInput, UTextarea, USelectMenu/USelect, USwitch, UCheckbox, UTabs, UBadge, UModal, and CommonDrawer when available.',
393
+ 'Use auto-injected components directly in the template with PascalCase names. Do not call resolveComponent() to manually resolve Nuxt UI/eApp components inside extension SFCs; it can compile but render unresolved lowercase DOM tags such as <ubutton>.',
363
394
  'Buttons should have stable geometry: hover may change color, border, or shadow but must not move the button or resize its content. Disabled buttons keep disabled cursor/visual state.',
364
395
  'Inputs and textareas should not add hover movement or decorative hover states; focus, invalid, disabled, and loading states must be explicit.',
365
396
  'Dynamic extensions resolve UModal to the app CommonModal. Do not pass ui.content: "eapp-surface-card" or "surface-card" to UModal/CommonModal; modal content uses the app modal surface and caller ui.content should only append z-index, width, or max-width classes.',
@@ -541,7 +572,66 @@ async function validateDynamicScript(apiUrl, sourceCode, scriptLanguage = 'javas
541
572
  };
542
573
  }
543
574
 
544
- async function validateExtensionCode(apiUrl, code, name) {
575
+ function readTemplateBlocks(code) {
576
+ const blocks = [];
577
+ const lower = String(code || '').toLowerCase();
578
+ let index = 0;
579
+ while (index < lower.length) {
580
+ const openStart = lower.indexOf('<template', index);
581
+ if (openStart === -1) break;
582
+ const boundary = lower[openStart + '<template'.length];
583
+ if (boundary && !/\s|>/.test(boundary)) {
584
+ index = openStart + 1;
585
+ continue;
586
+ }
587
+ const openEnd = lower.indexOf('>', openStart + '<template'.length);
588
+ if (openEnd === -1) break;
589
+ const closeStart = lower.indexOf('</template', openEnd + 1);
590
+ if (closeStart === -1) break;
591
+ blocks.push(String(code).slice(openEnd + 1, closeStart));
592
+ index = closeStart + '</template'.length;
593
+ }
594
+ return blocks;
595
+ }
596
+
597
+ function readTemplateTagName(template, start) {
598
+ const next = template[start + 1];
599
+ if (!next || next === '!' || next === '?') return null;
600
+ let index = start + (next === '/' ? 2 : 1);
601
+ while (/\s/.test(template[index] || '')) index += 1;
602
+ const nameStart = index;
603
+ while (/[\w.-]/.test(template[index] || '')) index += 1;
604
+ return index > nameStart ? template.slice(nameStart, index) : null;
605
+ }
606
+
607
+ export function validateExtensionCodeLocally(code) {
608
+ if (/\bresolveComponent\s*\(/.test(String(code || ''))) {
609
+ throw new Error('Invalid extension component resolution: do not call resolveComponent() in Enfyra extensions. Use auto-injected components such as <UButton> directly in the template so the app/compiler resolves them correctly.');
610
+ }
611
+
612
+ const violations = [];
613
+ for (const template of readTemplateBlocks(code)) {
614
+ let index = 0;
615
+ while (index < template.length) {
616
+ const tagStart = template.indexOf('<', index);
617
+ if (tagStart === -1) break;
618
+ const tagName = readTemplateTagName(template, tagStart);
619
+ if (tagName && tagName === tagName.toLowerCase() && !tagName.includes('-')) {
620
+ const expected = AUTO_INJECTED_EXTENSION_COMPONENT_BY_LOWERCASE.get(tagName);
621
+ if (expected) violations.push({ tag: tagName, expected });
622
+ }
623
+ index = tagStart + 1;
624
+ }
625
+ }
626
+ if (violations.length) {
627
+ const first = violations[0];
628
+ throw new Error(`Invalid extension component casing: use <${first.expected}> instead of <${first.tag}>. Enfyra/Nuxt UI auto-injected components must keep PascalCase in extension templates; lowercase tags render as unresolved DOM elements.`);
629
+ }
630
+ return { componentCasing: 'passed' };
631
+ }
632
+
633
+ export async function validateExtensionCode(apiUrl, code, name) {
634
+ const localChecks = validateExtensionCodeLocally(code);
545
635
  const result = await fetchAPI(apiUrl, '/enfyra_extension/preview', {
546
636
  method: 'POST',
547
637
  body: JSON.stringify({ code, name }),
@@ -551,11 +641,51 @@ async function validateExtensionCode(apiUrl, code, name) {
551
641
  }
552
642
  return {
553
643
  valid: true,
644
+ localChecks,
554
645
  extensionId: result?.extensionId || name || null,
555
646
  compiledLength: typeof result?.compiledCode === 'string' ? result.compiledCode.length : undefined,
556
647
  };
557
648
  }
558
649
 
650
+ async function updateExtensionCode(apiUrl, {
651
+ id,
652
+ name,
653
+ code,
654
+ description,
655
+ isEnabled,
656
+ version,
657
+ globalRulesAckKey,
658
+ extensionKnowledgeAckKey,
659
+ }) {
660
+ assertGlobalRulesAck(globalRulesAckKey);
661
+ assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
662
+ if (!id && !name) throw new Error('Provide id or name to update an existing extension.');
663
+ const existing = id
664
+ ? await findRecord(apiUrl, 'enfyra_extension', { id: { _eq: id } }, 'id,_id,name,type,menu.id')
665
+ : await findRecord(apiUrl, 'enfyra_extension', { name: { _eq: name } }, 'id,_id,name,type,menu.id');
666
+ if (!existing) throw new Error(`Extension not found: ${id || name}`);
667
+ const extensionId = getId(existing);
668
+ const validation = await validateExtensionCode(apiUrl, code, name || existing.name || extensionId);
669
+ const body = {
670
+ code,
671
+ ...(description !== undefined ? { description } : {}),
672
+ ...(isEnabled !== undefined ? { isEnabled } : {}),
673
+ ...(version !== undefined ? { version } : {}),
674
+ };
675
+ const result = await fetchAPI(apiUrl, `/enfyra_extension/${encodeURIComponent(String(extensionId))}`, {
676
+ method: 'PATCH',
677
+ body: JSON.stringify(body),
678
+ });
679
+ return {
680
+ action: 'extension_code_updated',
681
+ id: extensionId,
682
+ name: existing.name || name || null,
683
+ type: existing.type || null,
684
+ result,
685
+ validation,
686
+ };
687
+ }
688
+
559
689
  function normalizeMetadataTables(metadata) {
560
690
  const tables = metadata?.data?.tables || metadata?.tables || metadata?.data || [];
561
691
  return Array.isArray(tables) ? tables : Object.values(tables || {});
@@ -1436,7 +1566,8 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1436
1566
  'validate_extension_code',
1437
1567
  [
1438
1568
  'Validate Enfyra admin extension code before saving it to enfyra_extension.',
1439
- 'Use this for Vue SFC page/widget/global extension code. It calls /enfyra_extension/preview and does not save anything.',
1569
+ 'Use this only when the user explicitly wants a validation-only check. For normal edits, use update_extension_code or ensure_*_extension so successful validation saves in the same tool call.',
1570
+ 'This calls /enfyra_extension/preview and does not save anything.',
1440
1571
  'Call get_extension_theme_contract first when generating or reviewing UI.',
1441
1572
  ].join(' '),
1442
1573
  {
@@ -1449,6 +1580,27 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1449
1580
  }),
1450
1581
  );
1451
1582
 
1583
+ server.tool(
1584
+ 'update_extension_code',
1585
+ [
1586
+ 'Business operation: update an existing Enfyra admin extension code by id or name.',
1587
+ 'It runs local extension guards and /enfyra_extension/preview first, then saves the code in the same call only when validation succeeds.',
1588
+ 'Use this instead of validate_extension_code followed by update_record when editing an existing page/widget/global extension.',
1589
+ 'Call get_extension_theme_contract first when generating or reviewing UI.',
1590
+ ].join(' '),
1591
+ {
1592
+ id: z.union([z.string(), z.number()]).optional().describe('Existing extension id. Provide id or name.'),
1593
+ name: z.string().optional().describe('Existing extension unique name. Provide id or name.'),
1594
+ code: z.string().describe('Vue SFC extension code.'),
1595
+ description: z.string().optional().describe('Optional replacement extension description. Omit to preserve.'),
1596
+ isEnabled: z.boolean().optional().describe('Optional enabled state. Omit to preserve.'),
1597
+ version: z.string().optional().describe('Optional extension version. Omit to preserve.'),
1598
+ globalRulesAckKey: globalRulesAckParam(z),
1599
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
1600
+ },
1601
+ async (input) => jsonText(await updateExtensionCode(ENFYRA_API_URL, input)),
1602
+ );
1603
+
1452
1604
  server.tool(
1453
1605
  'get_extension_theme_contract',
1454
1606
  'Return the concise Enfyra admin extension UI/theme/security contract. Call before writing or reviewing extension UI.',
@@ -175,6 +175,7 @@ export function buildRequiredKnowledgePayload() {
175
175
  id: 'extension-runtime-contract',
176
176
  rules: [
177
177
  'Save extensions as enfyra_extension Vue SFC records; no static import statements in extension code.',
178
+ 'Do not call resolveComponent() in extension SFCs. Use auto-injected components such as <UButton>, <UBadge>, <PermissionGate>, and <Widget> directly in the template so the app/compiler resolves them correctly.',
178
179
  'Load app packages with getPackages(["package-name"]) inside extension runtime code.',
179
180
  'Prefer FormEditor/FormEditorLazy for direct table-backed forms when the form maps to metadata fields.',
180
181
  'For long admin setup workflows, open CommonDrawer immediately and show loading/error/content inside it.',
@@ -67,14 +67,14 @@ export const TOOL_WORKFLOWS = [
67
67
  firstTools: ['get_enfyra_required_knowledge', 'get_extension_theme_contract', 'inspect_feature'],
68
68
  inspectTools: ['inspect_feature', 'trace_metadata_usage', 'get_script_source'],
69
69
  knowledgeTools: ['get_enfyra_required_knowledge', 'get_extension_theme_contract', 'get_theme_class_reference'],
70
- writeTools: ['extension_workflow', 'ensure_menu', 'reorder_menus', 'ensure_page_extension', 'ensure_global_extension', 'ensure_widget_extension'],
70
+ writeTools: ['extension_workflow', 'ensure_menu', 'reorder_menus', 'update_extension_code', 'ensure_page_extension', 'ensure_global_extension', 'ensure_widget_extension'],
71
71
  verifyTools: ['validate_extension_code', 'inspect_feature'],
72
72
  avoidTools: [
73
73
  {
74
74
  tool: 'create_record/update_record on enfyra_extension',
75
75
  when: 'creating or changing extension code',
76
- useInstead: 'extension_workflow, ensure_page_extension, ensure_global_extension, or ensure_widget_extension',
77
- reason: 'Workflow and ensure tools validate extension code and preserve extension/menu contracts before saving.',
76
+ useInstead: 'update_extension_code for an existing extension id/name, or extension_workflow/ensure_*_extension for create/wire flows',
77
+ reason: 'Extension operation tools validate local guards plus /enfyra_extension/preview and save only after validation succeeds.',
78
78
  },
79
79
  {
80
80
  tool: 'query_table on destination domain lists',
@@ -22,7 +22,7 @@ import { buildMcpServerInstructions, buildGraphqlUrls } from './lib/mcp-instruct
22
22
  import { getExamples, listExampleCategories } from './lib/mcp-examples.js';
23
23
  import { WORKFLOW_SURFACES, discoverWorkflowRoutes } from './lib/tool-routing.js';
24
24
  import { registerTableTools } from './lib/table-tools.js';
25
- import { registerPlatformOperationTools } from './lib/platform-operation-tools.js';
25
+ import { registerPlatformOperationTools, validateExtensionCode } from './lib/platform-operation-tools.js';
26
26
  import { parseRecordData, prepareRecordMutation, validateScriptSourceIfPresent } from './lib/mutation-guards.js';
27
27
  import {
28
28
  assertDynamicCodeKnowledgeAck,
@@ -623,6 +623,11 @@ function assertKnowledgeForGenericMutation(tableName, data, { knowledgeAckKey, e
623
623
  assertExtensionKnowledgeAckIf(tableName === 'enfyra_extension' && typeof payload.code === 'string', extensionKnowledgeAckKey);
624
624
  }
625
625
 
626
+ async function validateExtensionCodeForGenericMutation(tableName, payload, fallbackName) {
627
+ if (tableName !== 'enfyra_extension' || typeof payload?.code !== 'string') return null;
628
+ return validateExtensionCode(ENFYRA_API_URL, payload.code, payload.name || fallbackName);
629
+ }
630
+
626
631
  function parseQueryParamsArg(queryParams) {
627
632
  const parsed = parseJsonArg(queryParams, {});
628
633
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
@@ -1501,7 +1506,7 @@ server.tool(
1501
1506
  // CRUD TOOLS
1502
1507
  // ============================================================================
1503
1508
 
1504
- server.tool('create_record', 'Create a new record in any route-backed table. The tool validates body keys against live metadata and validates sourceCode before saving script-backed records.', {
1509
+ server.tool('create_record', 'Create a new record in any route-backed table. The tool validates body keys against live metadata, validates sourceCode before saving script-backed records, and validates enfyra_extension.code before saving extension records.', {
1505
1510
  tableName: z.string().describe('Table name to insert into'),
1506
1511
  data: z.string().describe('Record data as JSON string'),
1507
1512
  queryParams: z.string().optional().describe('Optional query params as JSON object string, e.g. {"expired_at":"2026-09-20"}. Use for route contracts that intentionally keep workflow fields out of the validated body.'),
@@ -1513,15 +1518,17 @@ server.tool('create_record', 'Create a new record in any route-backed table. The
1513
1518
  validateTableName(tableName);
1514
1519
  assertKnowledgeForGenericMutation(tableName, data, { knowledgeAckKey, extensionKnowledgeAckKey });
1515
1520
  const prepared = await prepareGenericMutation(tableName, data);
1521
+ const extensionValidation = await validateExtensionCodeForGenericMutation(tableName, prepared.payload, prepared.payload?.name);
1516
1522
  const query = parseQueryParamsArg(queryParams);
1517
1523
  const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}`, query), { method: 'POST', body: JSON.stringify(prepared.payload) });
1518
1524
  return { content: [{ type: 'text', text: JSON.stringify({
1519
1525
  ...summarizeMutationResult(result, 'created', tableName),
1520
1526
  scriptValidation: prepared.scriptValidation,
1527
+ extensionValidation,
1521
1528
  }, null, 2) }] };
1522
1529
  });
1523
1530
 
1524
- server.tool('update_record', 'Update an existing record by ID using PATCH. The tool validates body keys against live metadata and validates sourceCode before saving script-backed records.', {
1531
+ server.tool('update_record', 'Update an existing record by ID using PATCH. The tool validates body keys against live metadata, validates sourceCode before saving script-backed records, and validates enfyra_extension.code before saving extension records. Prefer update_extension_code for normal extension edits.', {
1525
1532
  tableName: z.string().describe('Table name'),
1526
1533
  id: z.string().describe('Record ID to update'),
1527
1534
  data: z.string().describe('Fields to update as JSON string'),
@@ -1534,11 +1541,13 @@ server.tool('update_record', 'Update an existing record by ID using PATCH. The t
1534
1541
  validateTableName(tableName);
1535
1542
  assertKnowledgeForGenericMutation(tableName, data, { knowledgeAckKey, extensionKnowledgeAckKey });
1536
1543
  const prepared = await prepareGenericMutation(tableName, data);
1544
+ const extensionValidation = await validateExtensionCodeForGenericMutation(tableName, prepared.payload, id);
1537
1545
  const query = parseQueryParamsArg(queryParams);
1538
1546
  const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}/${id}`, query), { method: 'PATCH', body: JSON.stringify(prepared.payload) });
1539
1547
  return { content: [{ type: 'text', text: JSON.stringify({
1540
1548
  ...summarizeMutationResult(result, 'updated', tableName),
1541
1549
  scriptValidation: prepared.scriptValidation,
1550
+ extensionValidation,
1542
1551
  }, null, 2) }] };
1543
1552
  });
1544
1553