@enfyra/mcp-server 0.0.98 → 0.0.99

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
@@ -189,7 +189,7 @@ The MCP server includes safety guards for LLM callers:
189
189
  - Relation tools reject physical FK/junction names.
190
190
  - Generated code should use relation property names such as `conversation`, `sender`, and `member` instead of physical FK fields such as `conversationId`, `senderId`, or `memberId`.
191
191
  - Custom route tools reject `mainTableId` unless the route is the canonical table route.
192
- - Platform operation tools such as `create_api_endpoint`, `set_route_public_methods`, `set_table_graphql`, `ensure_guard`, `ensure_field_permission`, `ensure_column_rule`, `ensure_websocket_event`, `ensure_flow_step`, and `ensure_menu_extension_page` resolve metadata ids and validate code before saving.
192
+ - Platform operation tools such as `create_api_endpoint`, `publish_route_methods`, `add_route_methods`, `set_table_graphql`, `ensure_guard`, `ensure_field_permission`, `ensure_column_rule`, `ensure_websocket_event`, `ensure_script_flow_step`, `ensure_menu`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension` resolve metadata ids and validate code before saving.
193
193
  - Schema changes are serialized.
194
194
  - Destructive deletes return a preview before requiring `confirm=true`.
195
195
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.98",
3
+ "version": "0.0.99",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -996,13 +996,12 @@ ensure_route_access({
996
996
  },
997
997
  {
998
998
  name: 'Publish read-only route',
999
- code: `set_route_public_methods({
999
+ code: `publish_route_methods({
1000
1000
  path: "/articles",
1001
- methods: ["GET"],
1002
- mode: "merge"
1001
+ methods: ["GET"]
1003
1002
  })`,
1004
1003
  notes: [
1005
- 'Use set_route_public_methods instead of raw enfyra_route updates; the tool resolves method ids and validates that GET is available.',
1004
+ 'Use publish_route_methods instead of raw enfyra_route updates; the tool resolves method ids and validates that GET is available.',
1006
1005
  'publicMethods controls anonymous route access. Route permissions are not for public access.',
1007
1006
  'Route permissions apply when the method is not public.',
1008
1007
  ],
@@ -1417,7 +1416,7 @@ update_method({
1417
1416
  },
1418
1417
  {
1419
1418
  name: 'Create menu then extension',
1420
- code: `create_menu({
1419
+ code: `ensure_menu({
1421
1420
  label: "Reports",
1422
1421
  type: "Menu",
1423
1422
  path: "/reports",
@@ -1433,8 +1432,7 @@ update_method({
1433
1432
  })
1434
1433
 
1435
1434
  // Read the created menu id from the tool response, then:
1436
- create_extension({
1437
- type: "page",
1435
+ ensure_page_extension({
1438
1436
  name: "ReportsPage",
1439
1437
  description: "Reports dashboard",
1440
1438
  menuId: "<created-menu-id>",
@@ -1445,7 +1443,7 @@ create_extension({
1445
1443
  'Menu provides navigation; extension provides content.',
1446
1444
  'Use enfyra_menu.label, not title.',
1447
1445
  'Sensitive admin menus should include a permission condition at creation time.',
1448
- 'For page extensions, create the menu first and pass menuId to create_extension.',
1446
+ 'For page extensions, create the menu first with ensure_menu and pass its id to ensure_page_extension.',
1449
1447
  'Page extensions must register the app-shell PageHeader with usePageHeaderRegistry instead of rendering a custom top header.',
1450
1448
  'Use variant: "minimal" for operational pages unless a larger header is intentionally needed.',
1451
1449
  'Do not put ordinary KPI cards in PageHeader.stats; render metrics in the extension body.',
@@ -1488,8 +1486,7 @@ function openLatest() {
1488
1486
  </script>
1489
1487
  \`
1490
1488
 
1491
- create_extension({
1492
- type: "widget",
1489
+ ensure_widget_extension({
1493
1490
  name: "ReportStatusWidget",
1494
1491
  description: "Report status summary cards",
1495
1492
  code: reportStatusWidgetCode,
@@ -1497,8 +1494,7 @@ create_extension({
1497
1494
  })
1498
1495
 
1499
1496
  // Read the created widget record id, then embed it from the page extension.
1500
- create_extension({
1501
- type: "page",
1497
+ ensure_page_extension({
1502
1498
  name: "ReportsPage",
1503
1499
  menuId: "<reports-menu-id>",
1504
1500
  code: "<template><section class=\\"min-h-full w-full space-y-4\\"><Widget :id=\\"<report-status-widget-id>\\" :total=\\"totalReports\\" :rows=\\"reportRows\\" :open-details=\\"openReportDetails\\" @refresh=\\"refresh\\" /><Widget :id=\\"<report-table-widget-id>\\" :rows=\\"reportRows\\" @refresh=\\"refresh\\" /></section></template><script setup>const { registerPageHeader } = usePageHeaderRegistry(); registerPageHeader({ title: 'Reports', description: 'Operational report overview.', leadingIcon: 'lucide:bar-chart-3', gradient: 'cyan', variant: 'minimal' }); const totalReports = ref(0); const reportRows = ref([]); function refresh() {} function openReportDetails(row) { navigateTo('/data/report?filter=' + encodeURIComponent(JSON.stringify({ id: { _eq: row.id } }))) }</script>",
@@ -1574,8 +1570,7 @@ onUnmounted(() => {
1574
1570
  </script>
1575
1571
  \`
1576
1572
 
1577
- create_extension({
1578
- type: "global",
1573
+ ensure_global_extension({
1579
1574
  name: "NotificationBellGlobal",
1580
1575
  description: "Registers the app-wide notification bell in the account panel",
1581
1576
  code: notificationBellCode,
@@ -1719,7 +1714,7 @@ registerHeaderActions([
1719
1714
  {
1720
1715
  name: 'Plan an admin dashboard as multiple pages',
1721
1716
  code: `// Recommended menu shape for an operations surface:
1722
- create_menu({
1717
+ ensure_menu({
1723
1718
  type: "Dropdown Menu",
1724
1719
  label: "Operations",
1725
1720
  path: "/operations",
@@ -29,7 +29,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
29
29
  '- Inspect narrowly. Use `inspect_table`, `inspect_route`, and `inspect_feature` for the table/route/feature being changed instead of loading broad metadata.',
30
30
  '- Load examples only when needed. Before generating schemas, app connection code, OAuth, Socket.IO, handlers/hooks, flows, files, guards, permissions, or extensions, call `get_enfyra_examples` with the matching category.',
31
31
  '- For server scripts, call `discover_script_contexts` before writing or reviewing handler/hook/flow/websocket/GraphQL logic.',
32
- '- Prefer business operation tools over raw metadata CRUD for platform operations: `create_api_endpoint`, `ensure_route_methods`, `set_route_public_methods`, `set_table_graphql`, `ensure_guard`, `ensure_field_permission`, `ensure_column_rule`, `ensure_websocket_gateway`, `ensure_websocket_event`, `ensure_flow`, `ensure_flow_step`, and `ensure_menu_extension_page`.',
32
+ '- Prefer the most specific business operation tool over raw metadata CRUD: `create_api_endpoint`; route tools such as `add_route_methods`, `publish_route_methods`, and `unpublish_route_methods`; `set_table_graphql`; `ensure_guard`; permission/rule tools; websocket tools; flow tools such as `ensure_manual_flow`, `ensure_scheduled_flow`, and fixed-type flow step tools; and extension tools such as `ensure_menu`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension`.',
33
33
  '- 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.',
34
34
  '- 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.',
35
35
  '- Validate behavior with `test_rest_endpoint`, `run_admin_test`, `test_flow_step`, or the route-specific tool before claiming a dynamic feature works.',
@@ -104,6 +104,66 @@ async function resolveRoute(apiUrl, { path, routeId }) {
104
104
  return { route, routes, path: route.path };
105
105
  }
106
106
 
107
+ async function updateRouteMethods(apiUrl, { path, routeId, methods, mode, isEnabled }) {
108
+ const [{ route }, { methodMap, methodIdNameMap }] = await Promise.all([
109
+ resolveRoute(apiUrl, { path, routeId }),
110
+ getMethodContext(apiUrl),
111
+ ]);
112
+ const existingAvailable = methodNamesFromRecords(route.availableMethods, methodIdNameMap);
113
+ const existingPublic = methodNamesFromRecords(route.publicMethods, methodIdNameMap);
114
+ const finalAvailable = mergeMethods(existingAvailable, methods, mode);
115
+ const finalPublic = existingPublic.filter((method) => finalAvailable.includes(method));
116
+ const body = {
117
+ availableMethods: resolveMethodRefs(methodMap, finalAvailable),
118
+ publicMethods: resolveMethodRefs(methodMap, finalPublic),
119
+ };
120
+ if (isEnabled !== undefined) body.isEnabled = isEnabled;
121
+
122
+ const result = await fetchAPI(apiUrl, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, {
123
+ method: 'PATCH',
124
+ body: JSON.stringify(body),
125
+ });
126
+ const routeReload = await reloadRoutes(apiUrl);
127
+ return {
128
+ action: 'route_methods_updated',
129
+ route: { id: getId(route), path: route.path },
130
+ before: { availableMethods: existingAvailable, publicMethods: existingPublic },
131
+ after: { availableMethods: finalAvailable, publicMethods: finalPublic },
132
+ result,
133
+ routeReload,
134
+ };
135
+ }
136
+
137
+ async function updateRoutePublicMethods(apiUrl, { path, routeId, methods, mode }) {
138
+ const [{ route }, { methodMap, methodIdNameMap }] = await Promise.all([
139
+ resolveRoute(apiUrl, { path, routeId }),
140
+ getMethodContext(apiUrl),
141
+ ]);
142
+ const availableMethods = methodNamesFromRecords(route.availableMethods, methodIdNameMap);
143
+ const existingPublic = methodNamesFromRecords(route.publicMethods, methodIdNameMap);
144
+ const requestedMethods = uniqueMethodNames(methods);
145
+ const unavailable = requestedMethods.filter((method) => !availableMethods.includes(method));
146
+ if (unavailable.length > 0) {
147
+ throw new Error(`Cannot make unavailable route method(s) public: ${unavailable.join(', ')}. First call add_route_methods to add them to availableMethods.`);
148
+ }
149
+ const finalPublic = mergeMethods(existingPublic, requestedMethods, mode);
150
+ const result = await fetchAPI(apiUrl, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, {
151
+ method: 'PATCH',
152
+ body: JSON.stringify({ publicMethods: resolveMethodRefs(methodMap, finalPublic) }),
153
+ });
154
+ const routeReload = await reloadRoutes(apiUrl);
155
+ return {
156
+ action: 'route_public_methods_updated',
157
+ route: { id: getId(route), path: route.path },
158
+ availableMethods,
159
+ publicMethodsBefore: existingPublic,
160
+ publicMethodsAfter: finalPublic,
161
+ publicAccess: finalPublic.length > 0 ? 'Methods listed in publicMethods bypass auth/RoleGuard.' : 'No public methods remain on this route.',
162
+ result,
163
+ routeReload,
164
+ };
165
+ }
166
+
107
167
  async function findHandler(apiUrl, routeId, methodId) {
108
168
  const filter = encodeURIComponent(JSON.stringify({
109
169
  route: { id: { _eq: routeId } },
@@ -249,11 +309,10 @@ function normalizeFlowStepBody(step, flowId) {
249
309
  const body = {
250
310
  key: step.key,
251
311
  type: step.type,
252
- order: step.order ?? 0,
312
+ stepOrder: step.order ?? 0,
253
313
  config: step.config ?? {},
254
314
  timeout: step.timeout,
255
315
  isEnabled: step.isEnabled ?? true,
256
- description: step.description,
257
316
  flow: { id: flowId },
258
317
  };
259
318
  if (step.sourceCode !== undefined) body.sourceCode = step.sourceCode;
@@ -261,6 +320,138 @@ function normalizeFlowStepBody(step, flowId) {
261
320
  return Object.fromEntries(Object.entries(body).filter(([, value]) => value !== undefined));
262
321
  }
263
322
 
323
+ async function ensureMenu(apiUrl, {
324
+ label,
325
+ path,
326
+ icon,
327
+ type = 'Menu',
328
+ order = 0,
329
+ permission,
330
+ description,
331
+ isEnabled = true,
332
+ }) {
333
+ const normalizedPath = path ? normalizeRestPath(path) : undefined;
334
+ const existing = normalizedPath
335
+ ? await findRecord(apiUrl, 'enfyra_menu', { path: { _eq: normalizedPath } }, 'id,_id,path,label')
336
+ : await findRecord(apiUrl, 'enfyra_menu', { label: { _eq: label } }, 'id,_id,path,label');
337
+ const operation = await createOrPatch(apiUrl, 'enfyra_menu', existing, {
338
+ label,
339
+ ...(normalizedPath ? { path: normalizedPath } : {}),
340
+ icon,
341
+ type,
342
+ order,
343
+ permission: parseJsonObjectArg('permission', permission, undefined),
344
+ description,
345
+ isEnabled,
346
+ });
347
+ return {
348
+ id: operation.id || getId(existing),
349
+ path: normalizedPath || existing?.path || null,
350
+ label,
351
+ action: operation.action,
352
+ operation,
353
+ };
354
+ }
355
+
356
+ async function ensureExtension(apiUrl, {
357
+ name,
358
+ type,
359
+ code,
360
+ menuId,
361
+ description,
362
+ isEnabled = true,
363
+ version = '1.0.0',
364
+ }) {
365
+ if (type === 'page' && !menuId) {
366
+ throw new Error('menuId is required for page extensions. Use ensure_menu first, then ensure_page_extension.');
367
+ }
368
+ if (type !== 'page' && menuId) {
369
+ throw new Error('menuId is only valid for page extensions.');
370
+ }
371
+ const validation = await validateExtensionCode(apiUrl, code, name);
372
+ const existing = await findRecord(apiUrl, 'enfyra_extension', { name: { _eq: name } }, 'id,_id,name,menu.id,type');
373
+ const operation = await createOrPatch(apiUrl, 'enfyra_extension', existing, {
374
+ name,
375
+ type,
376
+ code,
377
+ ...(menuId ? { menu: { id: menuId } } : {}),
378
+ description,
379
+ isEnabled,
380
+ version,
381
+ });
382
+ return {
383
+ id: operation.id || getId(existing),
384
+ name,
385
+ type,
386
+ action: operation.action,
387
+ operation,
388
+ validation,
389
+ };
390
+ }
391
+
392
+ async function ensureFlow(apiUrl, {
393
+ name,
394
+ triggerType = 'manual',
395
+ triggerConfig,
396
+ timeout,
397
+ maxExecutions = 100,
398
+ isEnabled = true,
399
+ description,
400
+ }) {
401
+ const existing = await findRecord(apiUrl, 'enfyra_flow', { name: { _eq: name } }, 'id,_id,name');
402
+ const operation = await createOrPatch(apiUrl, 'enfyra_flow', existing, {
403
+ name,
404
+ triggerType,
405
+ triggerConfig: parseJsonObjectArg('triggerConfig', triggerConfig, {}),
406
+ timeout,
407
+ maxExecutions,
408
+ isEnabled,
409
+ description,
410
+ });
411
+ const reload = await reloadBestEffort(apiUrl, '/admin/reload/flows');
412
+ return { action: 'flow_ensured', flow: { id: operation.id, name }, operation, reload };
413
+ }
414
+
415
+ async function ensureFlowStep(apiUrl, {
416
+ flowName,
417
+ flowId,
418
+ key,
419
+ type,
420
+ order,
421
+ config,
422
+ sourceCode,
423
+ scriptLanguage,
424
+ timeout,
425
+ isEnabled,
426
+ }) {
427
+ if (!flowName && !flowId) throw new Error('Provide flowName or flowId.');
428
+ if (flowName && flowId) throw new Error('Provide flowName or flowId, not both.');
429
+ const flow = flowId
430
+ ? await findRecord(apiUrl, 'enfyra_flow', { id: { _eq: flowId } }, 'id,_id,name')
431
+ : await findRecord(apiUrl, 'enfyra_flow', { name: { _eq: flowName } }, 'id,_id,name');
432
+ if (!flow) throw new Error(`Flow not found: ${flowId || flowName}`);
433
+ const parsedConfig = parseJsonObjectArg('config', config, {});
434
+ const validation = sourceCode && ['script', 'condition'].includes(type)
435
+ ? await validateDynamicScript(apiUrl, sourceCode, scriptLanguage)
436
+ : { validated: false, reason: 'no script validation required' };
437
+ const existing = await findRecord(apiUrl, 'enfyra_flow_step', {
438
+ flow: { id: { _eq: getId(flow) } },
439
+ key: { _eq: key },
440
+ }, 'id,_id,key,flow.id');
441
+ const operation = await createOrPatch(apiUrl, 'enfyra_flow_step', existing, normalizeFlowStepBody({
442
+ key,
443
+ type,
444
+ order,
445
+ config: parsedConfig,
446
+ sourceCode,
447
+ scriptLanguage,
448
+ timeout,
449
+ isEnabled,
450
+ }, getId(flow)));
451
+ const reload = await reloadBestEffort(apiUrl, '/admin/reload/flows');
452
+ return { action: 'flow_step_ensured', flow: { id: getId(flow), name: flow.name }, step: { id: operation.id, key, type }, validation, operation, reload };
453
+ }
454
+
264
455
  export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
265
456
  server.tool(
266
457
  'validate_dynamic_script',
@@ -321,102 +512,105 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
321
512
  );
322
513
 
323
514
  server.tool(
324
- 'ensure_route_methods',
325
- [
326
- 'Business operation: set which HTTP methods a route supports.',
327
- 'Use this instead of raw enfyra_route CRUD when adding/removing availableMethods on an existing route.',
328
- 'It resolves method ids, preserves route metadata, patches availableMethods, and reloads routes.',
329
- ].join(' '),
515
+ 'add_route_methods',
516
+ 'Business operation: add HTTP methods to an existing route.',
330
517
  {
331
518
  path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
332
519
  routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
333
- methods: z.array(z.string()).min(1).describe('HTTP method names to merge, replace, or remove.'),
334
- mode: z.enum(['merge', 'replace', 'remove']).optional().default('merge').describe('merge adds methods; replace sets exactly these methods; remove deletes these methods.'),
520
+ methods: z.array(z.string()).min(1).describe('HTTP method names to add.'),
335
521
  isEnabled: z.boolean().optional().describe('Optionally enable/disable the route in the same safe patch.'),
336
522
  },
337
- async ({ path, routeId, methods, mode, isEnabled }) => {
338
- const [{ route }, { methodMap, methodIdNameMap }] = await Promise.all([
339
- resolveRoute(ENFYRA_API_URL, { path, routeId }),
340
- getMethodContext(ENFYRA_API_URL),
341
- ]);
342
- const existingAvailable = methodNamesFromRecords(route.availableMethods, methodIdNameMap);
343
- const existingPublic = methodNamesFromRecords(route.publicMethods, methodIdNameMap);
344
- const finalAvailable = mergeMethods(existingAvailable, methods, mode);
345
- const finalPublic = existingPublic.filter((method) => finalAvailable.includes(method));
346
- const body = {
347
- availableMethods: resolveMethodRefs(methodMap, finalAvailable),
348
- publicMethods: resolveMethodRefs(methodMap, finalPublic),
349
- };
350
- if (isEnabled !== undefined) body.isEnabled = isEnabled;
523
+ async ({ path, routeId, methods, isEnabled }) => jsonText(await updateRouteMethods(ENFYRA_API_URL, {
524
+ path,
525
+ routeId,
526
+ methods,
527
+ mode: 'merge',
528
+ isEnabled,
529
+ })),
530
+ );
351
531
 
352
- const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, {
353
- method: 'PATCH',
354
- body: JSON.stringify(body),
355
- });
356
- const routeReload = await reloadRoutes(ENFYRA_API_URL);
357
- return {
358
- content: [{
359
- type: 'text',
360
- text: JSON.stringify({
361
- action: 'route_methods_updated',
362
- route: { id: getId(route), path: route.path },
363
- before: { availableMethods: existingAvailable, publicMethods: existingPublic },
364
- after: { availableMethods: finalAvailable, publicMethods: finalPublic },
365
- result,
366
- routeReload,
367
- }, null, 2),
368
- }],
369
- };
532
+ server.tool(
533
+ 'replace_route_methods',
534
+ 'Business operation: replace an existing route availableMethods list exactly.',
535
+ {
536
+ path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
537
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
538
+ methods: z.array(z.string()).min(1).describe('Exact HTTP method names for availableMethods.'),
539
+ isEnabled: z.boolean().optional().describe('Optionally enable/disable the route in the same safe patch.'),
370
540
  },
541
+ async ({ path, routeId, methods, isEnabled }) => jsonText(await updateRouteMethods(ENFYRA_API_URL, {
542
+ path,
543
+ routeId,
544
+ methods,
545
+ mode: 'replace',
546
+ isEnabled,
547
+ })),
371
548
  );
372
549
 
373
550
  server.tool(
374
- 'set_route_public_methods',
375
- [
376
- 'Business operation: publish or unpublish REST methods on a route.',
377
- 'Use this instead of raw enfyra_route CRUD when the user says a route/method should be public, anonymous, private, or not require login.',
378
- 'The tool only touches publicMethods, validates that requested methods are already available on the route, and reloads routes.',
379
- ].join(' '),
551
+ 'remove_route_methods',
552
+ 'Business operation: remove HTTP methods from an existing route.',
380
553
  {
381
554
  path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
382
555
  routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
383
- methods: z.array(z.string()).min(1).describe('HTTP method names to publish, replace, or remove from publicMethods.'),
384
- mode: z.enum(['merge', 'replace', 'remove']).optional().default('merge').describe('merge publishes methods; replace sets publicMethods exactly; remove makes these methods non-public.'),
556
+ methods: z.array(z.string()).min(1).describe('HTTP method names to remove.'),
557
+ isEnabled: z.boolean().optional().describe('Optionally enable/disable the route in the same safe patch.'),
385
558
  },
386
- async ({ path, routeId, methods, mode }) => {
387
- const [{ route }, { methodMap, methodIdNameMap }] = await Promise.all([
388
- resolveRoute(ENFYRA_API_URL, { path, routeId }),
389
- getMethodContext(ENFYRA_API_URL),
390
- ]);
391
- const availableMethods = methodNamesFromRecords(route.availableMethods, methodIdNameMap);
392
- const existingPublic = methodNamesFromRecords(route.publicMethods, methodIdNameMap);
393
- const requestedMethods = uniqueMethodNames(methods);
394
- const unavailable = requestedMethods.filter((method) => !availableMethods.includes(method));
395
- if (unavailable.length > 0) {
396
- throw new Error(`Cannot make unavailable route method(s) public: ${unavailable.join(', ')}. First call ensure_route_methods to add them to availableMethods.`);
397
- }
398
- const finalPublic = mergeMethods(existingPublic, requestedMethods, mode);
399
- const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, {
400
- method: 'PATCH',
401
- body: JSON.stringify({ publicMethods: resolveMethodRefs(methodMap, finalPublic) }),
402
- });
403
- const routeReload = await reloadRoutes(ENFYRA_API_URL);
404
- return {
405
- content: [{
406
- type: 'text',
407
- text: JSON.stringify({
408
- action: 'route_public_methods_updated',
409
- route: { id: getId(route), path: route.path },
410
- availableMethods,
411
- publicMethodsBefore: existingPublic,
412
- publicMethodsAfter: finalPublic,
413
- publicAccess: finalPublic.length > 0 ? 'Methods listed in publicMethods bypass auth/RoleGuard.' : 'No public methods remain on this route.',
414
- result,
415
- routeReload,
416
- }, null, 2),
417
- }],
418
- };
559
+ async ({ path, routeId, methods, isEnabled }) => jsonText(await updateRouteMethods(ENFYRA_API_URL, {
560
+ path,
561
+ routeId,
562
+ methods,
563
+ mode: 'remove',
564
+ isEnabled,
565
+ })),
566
+ );
567
+
568
+ server.tool(
569
+ 'publish_route_methods',
570
+ 'Business operation: make existing route methods public/anonymous.',
571
+ {
572
+ path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
573
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
574
+ methods: z.array(z.string()).min(1).describe('HTTP method names to publish. They must already be available on the route.'),
419
575
  },
576
+ async ({ path, routeId, methods }) => jsonText(await updateRoutePublicMethods(ENFYRA_API_URL, {
577
+ path,
578
+ routeId,
579
+ methods,
580
+ mode: 'merge',
581
+ })),
582
+ );
583
+
584
+ server.tool(
585
+ 'replace_public_route_methods',
586
+ 'Business operation: replace a route publicMethods list exactly.',
587
+ {
588
+ path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
589
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
590
+ methods: z.array(z.string()).describe('Exact HTTP method names that should be public. Use an empty array to make all methods private.'),
591
+ },
592
+ async ({ path, routeId, methods }) => jsonText(await updateRoutePublicMethods(ENFYRA_API_URL, {
593
+ path,
594
+ routeId,
595
+ methods,
596
+ mode: 'replace',
597
+ })),
598
+ );
599
+
600
+ server.tool(
601
+ 'unpublish_route_methods',
602
+ 'Business operation: make specific public route methods private again.',
603
+ {
604
+ path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
605
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
606
+ methods: z.array(z.string()).min(1).describe('HTTP method names to remove from publicMethods.'),
607
+ },
608
+ async ({ path, routeId, methods }) => jsonText(await updateRoutePublicMethods(ENFYRA_API_URL, {
609
+ path,
610
+ routeId,
611
+ methods,
612
+ mode: 'remove',
613
+ })),
420
614
  );
421
615
 
422
616
  server.tool(
@@ -739,25 +933,25 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
739
933
 
740
934
  server.tool(
741
935
  'ensure_websocket_gateway',
742
- 'Business operation: create or update an Enfyra Socket.IO gateway. Connection handler code is validated before save.',
936
+ 'Business operation: create or update an Enfyra Socket.IO gateway. Connection handler sourceCode is validated before save.',
743
937
  {
744
938
  path: z.string().describe('Gateway namespace/path, e.g. /chat.'),
745
- connectionHandlerScript: z.string().optional().describe('Optional connection handler dynamic script.'),
939
+ sourceCode: z.string().optional().describe('Optional connection handler dynamic script sourceCode.'),
746
940
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for connection handler.'),
747
941
  isEnabled: z.boolean().optional().default(true).describe('Enable gateway.'),
748
942
  description: z.string().optional().describe('Admin note.'),
749
943
  },
750
- async ({ path, connectionHandlerScript, scriptLanguage, isEnabled, description }) => {
944
+ async ({ path, sourceCode, scriptLanguage, isEnabled, description }) => {
751
945
  const normalizedPath = normalizeRestPath(path);
752
- const validation = connectionHandlerScript === undefined
753
- ? { validated: false, reason: 'no connectionHandlerScript' }
754
- : await validateDynamicScript(ENFYRA_API_URL, connectionHandlerScript, scriptLanguage);
946
+ const validation = sourceCode === undefined
947
+ ? { validated: false, reason: 'no sourceCode' }
948
+ : await validateDynamicScript(ENFYRA_API_URL, sourceCode, scriptLanguage);
755
949
  const existing = await findRecord(ENFYRA_API_URL, 'enfyra_websocket', { path: { _eq: normalizedPath } }, 'id,_id,path');
756
950
  const body = {
757
951
  path: normalizedPath,
758
952
  isEnabled,
759
953
  description,
760
- ...(connectionHandlerScript !== undefined ? { connectionHandlerScript, scriptLanguage } : {}),
954
+ ...(sourceCode !== undefined ? { sourceCode, scriptLanguage } : {}),
761
955
  };
762
956
  const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_websocket', existing, body);
763
957
  const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/websockets');
@@ -767,24 +961,24 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
767
961
 
768
962
  server.tool(
769
963
  'ensure_websocket_event',
770
- 'Business operation: create or update one websocket event handler. It resolves gateway path/id and validates handlerScript before save.',
964
+ 'Business operation: create or update one websocket event handler. It resolves gateway path/id and validates sourceCode before save.',
771
965
  {
772
966
  gatewayPath: z.string().optional().describe('Gateway path, e.g. /chat. Use gatewayPath or gatewayId.'),
773
967
  gatewayId: z.union([z.string(), z.number()]).optional().describe('Gateway id. Use gatewayPath or gatewayId.'),
774
968
  eventName: z.string().describe('Socket event name.'),
775
- handlerScript: z.string().describe('Event handler dynamic script.'),
969
+ sourceCode: z.string().describe('Event handler dynamic script sourceCode.'),
776
970
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
777
971
  isEnabled: z.boolean().optional().default(true).describe('Enable event.'),
778
972
  description: z.string().optional().describe('Admin note.'),
779
973
  },
780
- async ({ gatewayPath, gatewayId, eventName, handlerScript, scriptLanguage, isEnabled, description }) => {
974
+ async ({ gatewayPath, gatewayId, eventName, sourceCode, scriptLanguage, isEnabled, description }) => {
781
975
  if (!gatewayPath && !gatewayId) throw new Error('Provide gatewayPath or gatewayId.');
782
976
  if (gatewayPath && gatewayId) throw new Error('Provide gatewayPath or gatewayId, not both.');
783
977
  const gateway = gatewayId
784
978
  ? await findRecord(ENFYRA_API_URL, 'enfyra_websocket', { id: { _eq: gatewayId } }, 'id,_id,path')
785
979
  : await findRecord(ENFYRA_API_URL, 'enfyra_websocket', { path: { _eq: normalizeRestPath(gatewayPath) } }, 'id,_id,path');
786
980
  if (!gateway) throw new Error(`Websocket gateway not found: ${gatewayId || gatewayPath}`);
787
- const validation = await validateDynamicScript(ENFYRA_API_URL, handlerScript, scriptLanguage);
981
+ const validation = await validateDynamicScript(ENFYRA_API_URL, sourceCode, scriptLanguage);
788
982
  const existing = await findRecord(ENFYRA_API_URL, 'enfyra_websocket_event', {
789
983
  gateway: { id: { _eq: getId(gateway) } },
790
984
  eventName: { _eq: eventName },
@@ -792,7 +986,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
792
986
  const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_websocket_event', existing, {
793
987
  gateway: { id: getId(gateway) },
794
988
  eventName,
795
- handlerScript,
989
+ sourceCode,
796
990
  scriptLanguage,
797
991
  isEnabled,
798
992
  description,
@@ -803,121 +997,225 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
803
997
  );
804
998
 
805
999
  server.tool(
806
- 'ensure_flow',
807
- 'Business operation: create or update an Enfyra flow. maxExecutions defaults to 100 when omitted.',
1000
+ 'ensure_manual_flow',
1001
+ 'Business operation: create or update a manually triggered Enfyra flow. Use this when the flow is run by API, admin action, another flow, or hook.',
808
1002
  {
809
1003
  name: z.string().describe('Flow name. Existing flow with this name is updated.'),
810
- trigger: z.string().optional().describe('Flow trigger type/key.'),
811
1004
  timeout: z.number().int().positive().optional().describe('Flow timeout in ms.'),
812
1005
  maxExecutions: z.number().int().positive().optional().default(100).describe('Execution history cap.'),
813
1006
  isEnabled: z.boolean().optional().default(true).describe('Enable flow.'),
814
1007
  description: z.string().optional().describe('Admin note.'),
815
1008
  },
816
- async ({ name, trigger, timeout, maxExecutions, isEnabled, description }) => {
817
- const existing = await findRecord(ENFYRA_API_URL, 'enfyra_flow', { name: { _eq: name } }, 'id,_id,name');
818
- const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_flow', existing, {
819
- name,
820
- trigger,
821
- timeout,
822
- maxExecutions,
823
- isEnabled,
824
- description,
825
- });
826
- const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/flows');
827
- return jsonText({ action: 'flow_ensured', flow: { id: operation.id, name }, operation, reload });
1009
+ async ({ name, timeout, maxExecutions, isEnabled, description }) => jsonText(await ensureFlow(ENFYRA_API_URL, {
1010
+ name,
1011
+ triggerType: 'manual',
1012
+ timeout,
1013
+ maxExecutions,
1014
+ isEnabled,
1015
+ description,
1016
+ })),
1017
+ );
1018
+
1019
+ server.tool(
1020
+ 'ensure_scheduled_flow',
1021
+ 'Business operation: create or update a scheduled Enfyra flow. Use this only for cron/time-based flows.',
1022
+ {
1023
+ name: z.string().describe('Flow name. Existing flow with this name is updated.'),
1024
+ triggerConfig: z.string().describe('Schedule config JSON object.'),
1025
+ timeout: z.number().int().positive().optional().describe('Flow timeout in ms.'),
1026
+ maxExecutions: z.number().int().positive().optional().default(100).describe('Execution history cap.'),
1027
+ isEnabled: z.boolean().optional().default(true).describe('Enable flow.'),
1028
+ description: z.string().optional().describe('Admin note.'),
828
1029
  },
1030
+ async ({ name, triggerConfig, timeout, maxExecutions, isEnabled, description }) => jsonText(await ensureFlow(ENFYRA_API_URL, {
1031
+ name,
1032
+ triggerType: 'schedule',
1033
+ triggerConfig,
1034
+ timeout,
1035
+ maxExecutions,
1036
+ isEnabled,
1037
+ description,
1038
+ })),
829
1039
  );
830
1040
 
831
1041
  server.tool(
832
- 'ensure_flow_step',
833
- 'Business operation: create or update one flow step by flow+key. Script/condition sourceCode is validated before save.',
1042
+ 'ensure_script_flow_step',
1043
+ 'Business operation: create or update one script flow step. Use this for JavaScript/TypeScript flow logic instead of choosing type=script manually.',
834
1044
  {
835
1045
  flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
836
1046
  flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
837
1047
  key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
838
- type: z.string().describe('Step type, e.g. script, condition, query, http, sleep, trigger_flow.'),
839
- order: z.number().optional().default(0).describe('Step order.'),
1048
+ sourceCode: z.string().describe('Script sourceCode.'),
1049
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
840
1050
  config: z.string().optional().describe('Step config JSON object.'),
841
- sourceCode: z.string().optional().describe('Script/condition sourceCode.'),
842
1051
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
843
1052
  timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
844
1053
  isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1054
+ },
1055
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1056
+ ...input,
1057
+ type: 'script',
1058
+ })),
1059
+ );
1060
+
1061
+ server.tool(
1062
+ 'ensure_condition_flow_step',
1063
+ 'Business operation: create or update one condition flow step. Use this for dynamic conditional branching instead of choosing type=condition manually.',
1064
+ {
1065
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
1066
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
1067
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
1068
+ sourceCode: z.string().describe('Condition sourceCode.'),
1069
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
1070
+ config: z.string().optional().describe('Step config JSON object.'),
1071
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
1072
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1073
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1074
+ },
1075
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1076
+ ...input,
1077
+ type: 'condition',
1078
+ })),
1079
+ );
1080
+
1081
+ server.tool(
1082
+ 'ensure_query_flow_step',
1083
+ 'Business operation: create or update one query flow step. Use this for repository/query-style flow steps instead of choosing type=query manually.',
1084
+ {
1085
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
1086
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
1087
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
1088
+ config: z.string().describe('Step config JSON object.'),
1089
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
1090
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1091
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1092
+ },
1093
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1094
+ ...input,
1095
+ type: 'query',
1096
+ })),
1097
+ );
1098
+
1099
+ server.tool(
1100
+ 'ensure_http_flow_step',
1101
+ 'Business operation: create or update one HTTP flow step. Use this for outbound HTTP calls instead of choosing type=http manually.',
1102
+ {
1103
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
1104
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
1105
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
1106
+ config: z.string().describe('Step config JSON object.'),
1107
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
1108
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1109
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1110
+ },
1111
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1112
+ ...input,
1113
+ type: 'http',
1114
+ })),
1115
+ );
1116
+
1117
+ server.tool(
1118
+ 'ensure_sleep_flow_step',
1119
+ 'Business operation: create or update one sleep/wait flow step. Use this for delays instead of choosing type=sleep manually.',
1120
+ {
1121
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
1122
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
1123
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
1124
+ config: z.string().describe('Step config JSON object.'),
1125
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
1126
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1127
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1128
+ },
1129
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1130
+ ...input,
1131
+ type: 'sleep',
1132
+ })),
1133
+ );
1134
+
1135
+ server.tool(
1136
+ 'ensure_trigger_flow_step',
1137
+ 'Business operation: create or update one child-flow trigger step. Use this for flow-to-flow orchestration instead of choosing type=trigger_flow manually.',
1138
+ {
1139
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
1140
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
1141
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
1142
+ config: z.string().describe('Step config JSON object.'),
1143
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
1144
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1145
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1146
+ },
1147
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1148
+ ...input,
1149
+ type: 'trigger_flow',
1150
+ })),
1151
+ );
1152
+
1153
+ server.tool(
1154
+ 'ensure_menu',
1155
+ 'Business operation: create or update one admin menu item. Use this instead of raw enfyra_menu CRUD.',
1156
+ {
1157
+ label: z.string().describe('Menu label.'),
1158
+ path: z.string().optional().describe('Admin app route path for leaf menu items, e.g. /reports.'),
1159
+ icon: z.string().optional().describe('Menu icon name.'),
1160
+ type: z.enum(['Menu', 'Dropdown Menu']).optional().default('Menu').describe('Menu type.'),
1161
+ order: z.number().optional().default(0).describe('Display order.'),
1162
+ permission: z.string().optional().describe('Menu permission JSON object.'),
845
1163
  description: z.string().optional().describe('Admin note.'),
1164
+ isEnabled: z.boolean().optional().default(true).describe('Enable menu.'),
846
1165
  },
847
- async ({ flowName, flowId, key, type, order, config, sourceCode, scriptLanguage, timeout, isEnabled, description }) => {
848
- if (!flowName && !flowId) throw new Error('Provide flowName or flowId.');
849
- if (flowName && flowId) throw new Error('Provide flowName or flowId, not both.');
850
- const flow = flowId
851
- ? await findRecord(ENFYRA_API_URL, 'enfyra_flow', { id: { _eq: flowId } }, 'id,_id,name')
852
- : await findRecord(ENFYRA_API_URL, 'enfyra_flow', { name: { _eq: flowName } }, 'id,_id,name');
853
- if (!flow) throw new Error(`Flow not found: ${flowId || flowName}`);
854
- const parsedConfig = parseJsonObjectArg('config', config, {});
855
- const validation = sourceCode && ['script', 'condition'].includes(type)
856
- ? await validateDynamicScript(ENFYRA_API_URL, sourceCode, scriptLanguage)
857
- : { validated: false, reason: 'no script validation required' };
858
- const existing = await findRecord(ENFYRA_API_URL, 'enfyra_flow_step', {
859
- flow: { id: { _eq: getId(flow) } },
860
- key: { _eq: key },
861
- }, 'id,_id,key,flow.id');
862
- const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_flow_step', existing, normalizeFlowStepBody({
863
- key,
864
- type,
865
- order,
866
- config: parsedConfig,
867
- sourceCode,
868
- scriptLanguage,
869
- timeout,
870
- isEnabled,
871
- description,
872
- }, getId(flow)));
873
- const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/flows');
874
- return jsonText({ action: 'flow_step_ensured', flow: { id: getId(flow), name: flow.name }, step: { id: operation.id, key, type }, validation, operation, reload });
1166
+ async (input) => jsonText({
1167
+ action: 'menu_ensured',
1168
+ menu: await ensureMenu(ENFYRA_API_URL, input),
1169
+ }),
1170
+ );
1171
+
1172
+ server.tool(
1173
+ 'ensure_page_extension',
1174
+ 'Business operation: create or update one page extension attached to an existing menu. Validates extension code before save.',
1175
+ {
1176
+ name: z.string().describe('Extension unique name.'),
1177
+ code: z.string().describe('Vue SFC extension code.'),
1178
+ menuId: z.union([z.string(), z.number()]).describe('Existing menu id for this page extension.'),
1179
+ description: z.string().optional().describe('Extension description.'),
1180
+ isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
1181
+ version: z.string().optional().default('1.0.0').describe('Extension version.'),
875
1182
  },
1183
+ async (input) => jsonText({
1184
+ action: 'page_extension_ensured',
1185
+ extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'page' }),
1186
+ }),
876
1187
  );
877
1188
 
878
1189
  server.tool(
879
- 'ensure_menu_extension_page',
880
- 'Business operation: create or update a page extension and its menu item together. Extension code is validated before save.',
1190
+ 'ensure_global_extension',
1191
+ 'Business operation: create or update one global shell extension. Validates extension code before save and rejects menu coupling.',
881
1192
  {
882
1193
  name: z.string().describe('Extension unique name.'),
883
1194
  code: z.string().describe('Vue SFC extension code.'),
884
- menuLabel: z.string().describe('Menu label.'),
885
- menuPath: z.string().describe('Admin app path, e.g. /reports.'),
886
- icon: z.string().optional().describe('Menu icon name.'),
887
- permission: z.string().optional().describe('Menu permission JSON object.'),
888
- description: z.string().optional().describe('Description for menu/extension.'),
889
- isEnabled: z.boolean().optional().default(true).describe('Enable menu and extension.'),
1195
+ description: z.string().optional().describe('Extension description.'),
1196
+ isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
890
1197
  version: z.string().optional().default('1.0.0').describe('Extension version.'),
891
1198
  },
892
- async ({ name, code, menuLabel, menuPath, icon, permission, description, isEnabled, version }) => {
893
- const validation = await validateExtensionCode(ENFYRA_API_URL, code, name);
894
- const normalizedPath = normalizeRestPath(menuPath);
895
- const existingMenu = await findRecord(ENFYRA_API_URL, 'enfyra_menu', { path: { _eq: normalizedPath } }, 'id,_id,path,label');
896
- const menuOperation = await createOrPatch(ENFYRA_API_URL, 'enfyra_menu', existingMenu, {
897
- label: menuLabel,
898
- path: normalizedPath,
899
- icon,
900
- type: 'Menu',
901
- permission: parseJsonObjectArg('permission', permission, undefined),
902
- description,
903
- isEnabled,
904
- });
905
- const existingExtension = await findRecord(ENFYRA_API_URL, 'enfyra_extension', { name: { _eq: name } }, 'id,_id,name,menu.id');
906
- const extensionOperation = await createOrPatch(ENFYRA_API_URL, 'enfyra_extension', existingExtension, {
907
- name,
908
- type: 'page',
909
- code,
910
- menu: { id: menuOperation.id || getId(existingMenu) },
911
- description,
912
- isEnabled,
913
- version,
914
- });
915
- return jsonText({
916
- action: 'menu_extension_page_ensured',
917
- menu: { id: menuOperation.id || getId(existingMenu), path: normalizedPath, action: menuOperation.action },
918
- extension: { id: extensionOperation.id || getId(existingExtension), name, action: extensionOperation.action },
919
- validation,
920
- });
1199
+ async (input) => jsonText({
1200
+ action: 'global_extension_ensured',
1201
+ extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'global' }),
1202
+ }),
1203
+ );
1204
+
1205
+ server.tool(
1206
+ 'ensure_widget_extension',
1207
+ 'Business operation: create or update one widget extension. Validates extension code before save and rejects menu coupling.',
1208
+ {
1209
+ name: z.string().describe('Extension unique name.'),
1210
+ code: z.string().describe('Vue SFC extension code.'),
1211
+ description: z.string().optional().describe('Extension description.'),
1212
+ isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
1213
+ version: z.string().optional().default('1.0.0').describe('Extension version.'),
921
1214
  },
1215
+ async (input) => jsonText({
1216
+ action: 'widget_extension_ensured',
1217
+ extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'widget' }),
1218
+ }),
922
1219
  );
1220
+
923
1221
  }
@@ -3054,69 +3054,6 @@ server.tool(
3054
3054
  },
3055
3055
  );
3056
3056
 
3057
- // ============================================================================
3058
- // MENU & EXTENSION TOOLS
3059
- // ============================================================================
3060
-
3061
- server.tool('create_menu', 'Create a menu item in the navigation. Use permission JSON for sensitive menu visibility; successful writes should trigger the app menu reload contract.', {
3062
- label: z.string().describe('Menu label'),
3063
- type: z.enum(['Menu', 'Dropdown Menu']).default('Menu').describe('Menu type: "Menu" for leaf items, "Dropdown Menu" for items with children'),
3064
- icon: z.string().optional().describe('Lucide icon name'),
3065
- path: z.string().optional().describe('App route path for a clickable menu item, e.g. "/reports".'),
3066
- externalUrl: z.string().optional().describe('External URL for a menu item when the backend supports external links.'),
3067
- order: z.number().optional().default(0).describe('Display order'),
3068
- isEnabled: z.boolean().optional().default(true).describe('Enable menu'),
3069
- description: z.string().optional().describe('Menu description'),
3070
- permission: z.string().optional().describe('Optional menu visibility permission JSON object string, e.g. {"or":[{"route":"/reports","methods":["GET"]}]}'),
3071
- }, async (data) => {
3072
- const body = { ...data };
3073
- if (body.permission !== undefined) {
3074
- body.permission = parseJsonArg(body.permission);
3075
- if (!body.permission || typeof body.permission !== 'object' || Array.isArray(body.permission)) {
3076
- throw new Error('permission must be a JSON object string.');
3077
- }
3078
- }
3079
- if (body.path && !body.path.startsWith('/')) {
3080
- body.path = '/' + body.path;
3081
- }
3082
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_menu', { method: 'POST', body: JSON.stringify(body) });
3083
- const created = firstDataRecord(result);
3084
- return { content: [{ type: 'text', text: `Menu created (ID: ${getId(created)}):\n${JSON.stringify(result, null, 2)}` }] };
3085
- });
3086
-
3087
- server.tool(
3088
- 'create_extension',
3089
- [
3090
- 'Create an extension (Vue SFC page, widget, or global shell extension). Code must be Vue SFC: <template>...</template> + <script setup>...</script> — NO imports, use globals (ref, useToast, useApi, UButton, etc).',
3091
- 'For type=page: create menu first (create_menu), get id, then pass menuId. For type=widget: no menu, embed via <Widget>. For type=global: no menu, the Enfyra admin UI mounts it invisibly at shell level for registries/realtime. Server auto-compiles and should emit realtime reload to open Enfyra admin tabs. See extension rules in MCP instructions.',
3092
- ].join(' '),
3093
- {
3094
- name: z.string().describe('Extension name (unique)'),
3095
- type: z.enum(['page', 'widget', 'global']).describe('Extension type: page = full page linked to menu; widget = embed via Widget component; global = shell-level lifecycle component'),
3096
- code: z.string().describe('Vue SFC string — <template> + <script setup>, NO import statements'),
3097
- menuId: z.string().optional().describe('Required for type=page — enfyra_menu id from create_menu. Omit for widget/global'),
3098
- isEnabled: z.boolean().optional().default(true).describe('Enable extension'),
3099
- description: z.string().optional().describe('Extension description'),
3100
- version: z.string().optional().default('1.0.0').describe('Extension version'),
3101
- },
3102
- async (data) => {
3103
- const body = { ...data };
3104
- if (body.type === 'page' && !body.menuId) {
3105
- throw new Error('menuId is required for type=page. Create or find a enfyra_menu record first.');
3106
- }
3107
- if (body.type !== 'page' && body.menuId) {
3108
- throw new Error('menuId is only valid for type=page. Omit menuId for widget/global extensions.');
3109
- }
3110
- if (body.menuId) {
3111
- body.menu = { id: body.menuId };
3112
- delete body.menuId;
3113
- }
3114
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_extension', { method: 'POST', body: JSON.stringify(body) });
3115
- const created = firstDataRecord(result);
3116
- return { content: [{ type: 'text', text: `Extension created (ID: ${getId(created)}). Open Enfyra admin tabs should update through the realtime reload contract.\n${JSON.stringify(result, null, 2)}` }] };
3117
- },
3118
- );
3119
-
3120
3057
  // ============================================================================
3121
3058
  // MAIN
3122
3059
  // ============================================================================