@enfyra/mcp-server 0.0.98 → 0.0.100

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.100",
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 } },
@@ -148,6 +208,10 @@ async function reloadBestEffort(apiUrl, path) {
148
208
  }
149
209
  }
150
210
 
211
+ function naturalPartialReload(reason) {
212
+ return { attempted: false, succeeded: true, reason };
213
+ }
214
+
151
215
  async function validateDynamicScript(apiUrl, sourceCode, scriptLanguage = 'javascript') {
152
216
  const result = await fetchAPI(apiUrl, '/admin/script/validate', {
153
217
  method: 'POST',
@@ -249,11 +313,10 @@ function normalizeFlowStepBody(step, flowId) {
249
313
  const body = {
250
314
  key: step.key,
251
315
  type: step.type,
252
- order: step.order ?? 0,
316
+ stepOrder: step.order ?? 0,
253
317
  config: step.config ?? {},
254
318
  timeout: step.timeout,
255
319
  isEnabled: step.isEnabled ?? true,
256
- description: step.description,
257
320
  flow: { id: flowId },
258
321
  };
259
322
  if (step.sourceCode !== undefined) body.sourceCode = step.sourceCode;
@@ -261,6 +324,138 @@ function normalizeFlowStepBody(step, flowId) {
261
324
  return Object.fromEntries(Object.entries(body).filter(([, value]) => value !== undefined));
262
325
  }
263
326
 
327
+ async function ensureMenu(apiUrl, {
328
+ label,
329
+ path,
330
+ icon,
331
+ type = 'Menu',
332
+ order = 0,
333
+ permission,
334
+ description,
335
+ isEnabled = true,
336
+ }) {
337
+ const normalizedPath = path ? normalizeRestPath(path) : undefined;
338
+ const existing = normalizedPath
339
+ ? await findRecord(apiUrl, 'enfyra_menu', { path: { _eq: normalizedPath } }, 'id,_id,path,label')
340
+ : await findRecord(apiUrl, 'enfyra_menu', { label: { _eq: label } }, 'id,_id,path,label');
341
+ const operation = await createOrPatch(apiUrl, 'enfyra_menu', existing, {
342
+ label,
343
+ ...(normalizedPath ? { path: normalizedPath } : {}),
344
+ icon,
345
+ type,
346
+ order,
347
+ permission: parseJsonObjectArg('permission', permission, undefined),
348
+ description,
349
+ isEnabled,
350
+ });
351
+ return {
352
+ id: operation.id || getId(existing),
353
+ path: normalizedPath || existing?.path || null,
354
+ label,
355
+ action: operation.action,
356
+ operation,
357
+ };
358
+ }
359
+
360
+ async function ensureExtension(apiUrl, {
361
+ name,
362
+ type,
363
+ code,
364
+ menuId,
365
+ description,
366
+ isEnabled = true,
367
+ version = '1.0.0',
368
+ }) {
369
+ if (type === 'page' && !menuId) {
370
+ throw new Error('menuId is required for page extensions. Use ensure_menu first, then ensure_page_extension.');
371
+ }
372
+ if (type !== 'page' && menuId) {
373
+ throw new Error('menuId is only valid for page extensions.');
374
+ }
375
+ const validation = await validateExtensionCode(apiUrl, code, name);
376
+ const existing = await findRecord(apiUrl, 'enfyra_extension', { name: { _eq: name } }, 'id,_id,name,menu.id,type');
377
+ const operation = await createOrPatch(apiUrl, 'enfyra_extension', existing, {
378
+ name,
379
+ type,
380
+ code,
381
+ ...(menuId ? { menu: { id: menuId } } : {}),
382
+ description,
383
+ isEnabled,
384
+ version,
385
+ });
386
+ return {
387
+ id: operation.id || getId(existing),
388
+ name,
389
+ type,
390
+ action: operation.action,
391
+ operation,
392
+ validation,
393
+ };
394
+ }
395
+
396
+ async function ensureFlow(apiUrl, {
397
+ name,
398
+ triggerType = 'manual',
399
+ triggerConfig,
400
+ timeout,
401
+ maxExecutions = 100,
402
+ isEnabled = true,
403
+ description,
404
+ }) {
405
+ const existing = await findRecord(apiUrl, 'enfyra_flow', { name: { _eq: name } }, 'id,_id,name');
406
+ const operation = await createOrPatch(apiUrl, 'enfyra_flow', existing, {
407
+ name,
408
+ triggerType,
409
+ triggerConfig: parseJsonObjectArg('triggerConfig', triggerConfig, {}),
410
+ timeout,
411
+ maxExecutions,
412
+ isEnabled,
413
+ description,
414
+ });
415
+ const reload = naturalPartialReload('Flow metadata writes trigger the server partial reload contract; there is no dedicated flow reload endpoint.');
416
+ return { action: 'flow_ensured', flow: { id: operation.id, name }, operation, reload };
417
+ }
418
+
419
+ async function ensureFlowStep(apiUrl, {
420
+ flowName,
421
+ flowId,
422
+ key,
423
+ type,
424
+ order,
425
+ config,
426
+ sourceCode,
427
+ scriptLanguage,
428
+ timeout,
429
+ isEnabled,
430
+ }) {
431
+ if (!flowName && !flowId) throw new Error('Provide flowName or flowId.');
432
+ if (flowName && flowId) throw new Error('Provide flowName or flowId, not both.');
433
+ const flow = flowId
434
+ ? await findRecord(apiUrl, 'enfyra_flow', { id: { _eq: flowId } }, 'id,_id,name')
435
+ : await findRecord(apiUrl, 'enfyra_flow', { name: { _eq: flowName } }, 'id,_id,name');
436
+ if (!flow) throw new Error(`Flow not found: ${flowId || flowName}`);
437
+ const parsedConfig = parseJsonObjectArg('config', config, {});
438
+ const validation = sourceCode && ['script', 'condition'].includes(type)
439
+ ? await validateDynamicScript(apiUrl, sourceCode, scriptLanguage)
440
+ : { validated: false, reason: 'no script validation required' };
441
+ const existing = await findRecord(apiUrl, 'enfyra_flow_step', {
442
+ flow: { id: { _eq: getId(flow) } },
443
+ key: { _eq: key },
444
+ }, 'id,_id,key,flow.id');
445
+ const operation = await createOrPatch(apiUrl, 'enfyra_flow_step', existing, normalizeFlowStepBody({
446
+ key,
447
+ type,
448
+ order,
449
+ config: parsedConfig,
450
+ sourceCode,
451
+ scriptLanguage,
452
+ timeout,
453
+ isEnabled,
454
+ }, getId(flow)));
455
+ const reload = naturalPartialReload('Flow step writes trigger the server partial reload contract; there is no dedicated flow reload endpoint.');
456
+ return { action: 'flow_step_ensured', flow: { id: getId(flow), name: flow.name }, step: { id: operation.id, key, type }, validation, operation, reload };
457
+ }
458
+
264
459
  export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
265
460
  server.tool(
266
461
  'validate_dynamic_script',
@@ -321,102 +516,105 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
321
516
  );
322
517
 
323
518
  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(' '),
519
+ 'add_route_methods',
520
+ 'Business operation: add HTTP methods to an existing route.',
330
521
  {
331
522
  path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
332
523
  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.'),
524
+ methods: z.array(z.string()).min(1).describe('HTTP method names to add.'),
335
525
  isEnabled: z.boolean().optional().describe('Optionally enable/disable the route in the same safe patch.'),
336
526
  },
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;
527
+ async ({ path, routeId, methods, isEnabled }) => jsonText(await updateRouteMethods(ENFYRA_API_URL, {
528
+ path,
529
+ routeId,
530
+ methods,
531
+ mode: 'merge',
532
+ isEnabled,
533
+ })),
534
+ );
351
535
 
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
- };
536
+ server.tool(
537
+ 'replace_route_methods',
538
+ 'Business operation: replace an existing route availableMethods list exactly.',
539
+ {
540
+ path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
541
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
542
+ methods: z.array(z.string()).min(1).describe('Exact HTTP method names for availableMethods.'),
543
+ isEnabled: z.boolean().optional().describe('Optionally enable/disable the route in the same safe patch.'),
370
544
  },
545
+ async ({ path, routeId, methods, isEnabled }) => jsonText(await updateRouteMethods(ENFYRA_API_URL, {
546
+ path,
547
+ routeId,
548
+ methods,
549
+ mode: 'replace',
550
+ isEnabled,
551
+ })),
371
552
  );
372
553
 
373
554
  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(' '),
555
+ 'remove_route_methods',
556
+ 'Business operation: remove HTTP methods from an existing route.',
380
557
  {
381
558
  path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
382
559
  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.'),
560
+ methods: z.array(z.string()).min(1).describe('HTTP method names to remove.'),
561
+ isEnabled: z.boolean().optional().describe('Optionally enable/disable the route in the same safe patch.'),
385
562
  },
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
- };
563
+ async ({ path, routeId, methods, isEnabled }) => jsonText(await updateRouteMethods(ENFYRA_API_URL, {
564
+ path,
565
+ routeId,
566
+ methods,
567
+ mode: 'remove',
568
+ isEnabled,
569
+ })),
570
+ );
571
+
572
+ server.tool(
573
+ 'publish_route_methods',
574
+ 'Business operation: make existing route methods public/anonymous.',
575
+ {
576
+ path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
577
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
578
+ methods: z.array(z.string()).min(1).describe('HTTP method names to publish. They must already be available on the route.'),
419
579
  },
580
+ async ({ path, routeId, methods }) => jsonText(await updateRoutePublicMethods(ENFYRA_API_URL, {
581
+ path,
582
+ routeId,
583
+ methods,
584
+ mode: 'merge',
585
+ })),
586
+ );
587
+
588
+ server.tool(
589
+ 'replace_public_route_methods',
590
+ 'Business operation: replace a route publicMethods list exactly.',
591
+ {
592
+ path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
593
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
594
+ methods: z.array(z.string()).describe('Exact HTTP method names that should be public. Use an empty array to make all methods private.'),
595
+ },
596
+ async ({ path, routeId, methods }) => jsonText(await updateRoutePublicMethods(ENFYRA_API_URL, {
597
+ path,
598
+ routeId,
599
+ methods,
600
+ mode: 'replace',
601
+ })),
602
+ );
603
+
604
+ server.tool(
605
+ 'unpublish_route_methods',
606
+ 'Business operation: make specific public route methods private again.',
607
+ {
608
+ path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
609
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
610
+ methods: z.array(z.string()).min(1).describe('HTTP method names to remove from publicMethods.'),
611
+ },
612
+ async ({ path, routeId, methods }) => jsonText(await updateRoutePublicMethods(ENFYRA_API_URL, {
613
+ path,
614
+ routeId,
615
+ methods,
616
+ mode: 'remove',
617
+ })),
420
618
  );
421
619
 
422
620
  server.tool(
@@ -739,52 +937,52 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
739
937
 
740
938
  server.tool(
741
939
  'ensure_websocket_gateway',
742
- 'Business operation: create or update an Enfyra Socket.IO gateway. Connection handler code is validated before save.',
940
+ 'Business operation: create or update an Enfyra Socket.IO gateway. Connection handler sourceCode is validated before save.',
743
941
  {
744
942
  path: z.string().describe('Gateway namespace/path, e.g. /chat.'),
745
- connectionHandlerScript: z.string().optional().describe('Optional connection handler dynamic script.'),
943
+ sourceCode: z.string().optional().describe('Optional connection handler dynamic script sourceCode.'),
746
944
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for connection handler.'),
747
945
  isEnabled: z.boolean().optional().default(true).describe('Enable gateway.'),
748
946
  description: z.string().optional().describe('Admin note.'),
749
947
  },
750
- async ({ path, connectionHandlerScript, scriptLanguage, isEnabled, description }) => {
948
+ async ({ path, sourceCode, scriptLanguage, isEnabled, description }) => {
751
949
  const normalizedPath = normalizeRestPath(path);
752
- const validation = connectionHandlerScript === undefined
753
- ? { validated: false, reason: 'no connectionHandlerScript' }
754
- : await validateDynamicScript(ENFYRA_API_URL, connectionHandlerScript, scriptLanguage);
950
+ const validation = sourceCode === undefined
951
+ ? { validated: false, reason: 'no sourceCode' }
952
+ : await validateDynamicScript(ENFYRA_API_URL, sourceCode, scriptLanguage);
755
953
  const existing = await findRecord(ENFYRA_API_URL, 'enfyra_websocket', { path: { _eq: normalizedPath } }, 'id,_id,path');
756
954
  const body = {
757
955
  path: normalizedPath,
758
956
  isEnabled,
759
957
  description,
760
- ...(connectionHandlerScript !== undefined ? { connectionHandlerScript, scriptLanguage } : {}),
958
+ ...(sourceCode !== undefined ? { sourceCode, scriptLanguage } : {}),
761
959
  };
762
960
  const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_websocket', existing, body);
763
- const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/websockets');
961
+ const reload = naturalPartialReload('Websocket metadata writes trigger the server partial reload contract; there is no dedicated websocket reload endpoint.');
764
962
  return jsonText({ action: 'websocket_gateway_ensured', gateway: { id: operation.id, path: normalizedPath }, validation, operation, reload });
765
963
  },
766
964
  );
767
965
 
768
966
  server.tool(
769
967
  'ensure_websocket_event',
770
- 'Business operation: create or update one websocket event handler. It resolves gateway path/id and validates handlerScript before save.',
968
+ 'Business operation: create or update one websocket event handler. It resolves gateway path/id and validates sourceCode before save.',
771
969
  {
772
970
  gatewayPath: z.string().optional().describe('Gateway path, e.g. /chat. Use gatewayPath or gatewayId.'),
773
971
  gatewayId: z.union([z.string(), z.number()]).optional().describe('Gateway id. Use gatewayPath or gatewayId.'),
774
972
  eventName: z.string().describe('Socket event name.'),
775
- handlerScript: z.string().describe('Event handler dynamic script.'),
973
+ sourceCode: z.string().describe('Event handler dynamic script sourceCode.'),
776
974
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
777
975
  isEnabled: z.boolean().optional().default(true).describe('Enable event.'),
778
976
  description: z.string().optional().describe('Admin note.'),
779
977
  },
780
- async ({ gatewayPath, gatewayId, eventName, handlerScript, scriptLanguage, isEnabled, description }) => {
978
+ async ({ gatewayPath, gatewayId, eventName, sourceCode, scriptLanguage, isEnabled, description }) => {
781
979
  if (!gatewayPath && !gatewayId) throw new Error('Provide gatewayPath or gatewayId.');
782
980
  if (gatewayPath && gatewayId) throw new Error('Provide gatewayPath or gatewayId, not both.');
783
981
  const gateway = gatewayId
784
982
  ? await findRecord(ENFYRA_API_URL, 'enfyra_websocket', { id: { _eq: gatewayId } }, 'id,_id,path')
785
983
  : await findRecord(ENFYRA_API_URL, 'enfyra_websocket', { path: { _eq: normalizeRestPath(gatewayPath) } }, 'id,_id,path');
786
984
  if (!gateway) throw new Error(`Websocket gateway not found: ${gatewayId || gatewayPath}`);
787
- const validation = await validateDynamicScript(ENFYRA_API_URL, handlerScript, scriptLanguage);
985
+ const validation = await validateDynamicScript(ENFYRA_API_URL, sourceCode, scriptLanguage);
788
986
  const existing = await findRecord(ENFYRA_API_URL, 'enfyra_websocket_event', {
789
987
  gateway: { id: { _eq: getId(gateway) } },
790
988
  eventName: { _eq: eventName },
@@ -792,132 +990,236 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
792
990
  const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_websocket_event', existing, {
793
991
  gateway: { id: getId(gateway) },
794
992
  eventName,
795
- handlerScript,
993
+ sourceCode,
796
994
  scriptLanguage,
797
995
  isEnabled,
798
996
  description,
799
997
  });
800
- const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/websockets');
998
+ const reload = naturalPartialReload('Websocket event writes trigger the server partial reload contract; there is no dedicated websocket reload endpoint.');
801
999
  return jsonText({ action: 'websocket_event_ensured', gateway: { id: getId(gateway), path: gateway.path }, eventName, validation, operation, reload });
802
1000
  },
803
1001
  );
804
1002
 
805
1003
  server.tool(
806
- 'ensure_flow',
807
- 'Business operation: create or update an Enfyra flow. maxExecutions defaults to 100 when omitted.',
1004
+ 'ensure_manual_flow',
1005
+ '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
1006
  {
809
1007
  name: z.string().describe('Flow name. Existing flow with this name is updated.'),
810
- trigger: z.string().optional().describe('Flow trigger type/key.'),
811
1008
  timeout: z.number().int().positive().optional().describe('Flow timeout in ms.'),
812
1009
  maxExecutions: z.number().int().positive().optional().default(100).describe('Execution history cap.'),
813
1010
  isEnabled: z.boolean().optional().default(true).describe('Enable flow.'),
814
1011
  description: z.string().optional().describe('Admin note.'),
815
1012
  },
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 });
1013
+ async ({ name, timeout, maxExecutions, isEnabled, description }) => jsonText(await ensureFlow(ENFYRA_API_URL, {
1014
+ name,
1015
+ triggerType: 'manual',
1016
+ timeout,
1017
+ maxExecutions,
1018
+ isEnabled,
1019
+ description,
1020
+ })),
1021
+ );
1022
+
1023
+ server.tool(
1024
+ 'ensure_scheduled_flow',
1025
+ 'Business operation: create or update a scheduled Enfyra flow. Use this only for cron/time-based flows.',
1026
+ {
1027
+ name: z.string().describe('Flow name. Existing flow with this name is updated.'),
1028
+ triggerConfig: z.string().describe('Schedule config JSON object.'),
1029
+ timeout: z.number().int().positive().optional().describe('Flow timeout in ms.'),
1030
+ maxExecutions: z.number().int().positive().optional().default(100).describe('Execution history cap.'),
1031
+ isEnabled: z.boolean().optional().default(true).describe('Enable flow.'),
1032
+ description: z.string().optional().describe('Admin note.'),
828
1033
  },
1034
+ async ({ name, triggerConfig, timeout, maxExecutions, isEnabled, description }) => jsonText(await ensureFlow(ENFYRA_API_URL, {
1035
+ name,
1036
+ triggerType: 'schedule',
1037
+ triggerConfig,
1038
+ timeout,
1039
+ maxExecutions,
1040
+ isEnabled,
1041
+ description,
1042
+ })),
829
1043
  );
830
1044
 
831
1045
  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.',
1046
+ 'ensure_script_flow_step',
1047
+ 'Business operation: create or update one script flow step. Use this for JavaScript/TypeScript flow logic instead of choosing type=script manually.',
834
1048
  {
835
1049
  flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
836
1050
  flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
837
1051
  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.'),
1052
+ sourceCode: z.string().describe('Script sourceCode.'),
1053
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
840
1054
  config: z.string().optional().describe('Step config JSON object.'),
841
- sourceCode: z.string().optional().describe('Script/condition sourceCode.'),
842
1055
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
843
1056
  timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
844
1057
  isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1058
+ },
1059
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1060
+ ...input,
1061
+ type: 'script',
1062
+ })),
1063
+ );
1064
+
1065
+ server.tool(
1066
+ 'ensure_condition_flow_step',
1067
+ 'Business operation: create or update one condition flow step. Use this for dynamic conditional branching instead of choosing type=condition manually.',
1068
+ {
1069
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
1070
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
1071
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
1072
+ sourceCode: z.string().describe('Condition sourceCode.'),
1073
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
1074
+ config: z.string().optional().describe('Step config JSON object.'),
1075
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
1076
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1077
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1078
+ },
1079
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1080
+ ...input,
1081
+ type: 'condition',
1082
+ })),
1083
+ );
1084
+
1085
+ server.tool(
1086
+ 'ensure_query_flow_step',
1087
+ 'Business operation: create or update one query flow step. Use this for repository/query-style flow steps instead of choosing type=query manually.',
1088
+ {
1089
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
1090
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
1091
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
1092
+ config: z.string().describe('Step config JSON object.'),
1093
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
1094
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1095
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1096
+ },
1097
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1098
+ ...input,
1099
+ type: 'query',
1100
+ })),
1101
+ );
1102
+
1103
+ server.tool(
1104
+ 'ensure_http_flow_step',
1105
+ 'Business operation: create or update one HTTP flow step. Use this for outbound HTTP calls instead of choosing type=http manually.',
1106
+ {
1107
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
1108
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
1109
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
1110
+ config: z.string().describe('Step config JSON object.'),
1111
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
1112
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1113
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1114
+ },
1115
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1116
+ ...input,
1117
+ type: 'http',
1118
+ })),
1119
+ );
1120
+
1121
+ server.tool(
1122
+ 'ensure_sleep_flow_step',
1123
+ 'Business operation: create or update one sleep/wait flow step. Use this for delays instead of choosing type=sleep manually.',
1124
+ {
1125
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
1126
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
1127
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
1128
+ config: z.string().describe('Step config JSON object.'),
1129
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
1130
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1131
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1132
+ },
1133
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1134
+ ...input,
1135
+ type: 'sleep',
1136
+ })),
1137
+ );
1138
+
1139
+ server.tool(
1140
+ 'ensure_trigger_flow_step',
1141
+ 'Business operation: create or update one child-flow trigger step. Use this for flow-to-flow orchestration instead of choosing type=trigger_flow manually.',
1142
+ {
1143
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
1144
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
1145
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
1146
+ config: z.string().describe('Step config JSON object.'),
1147
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
1148
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1149
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1150
+ },
1151
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1152
+ ...input,
1153
+ type: 'trigger_flow',
1154
+ })),
1155
+ );
1156
+
1157
+ server.tool(
1158
+ 'ensure_menu',
1159
+ 'Business operation: create or update one admin menu item. Use this instead of raw enfyra_menu CRUD.',
1160
+ {
1161
+ label: z.string().describe('Menu label.'),
1162
+ path: z.string().optional().describe('Admin app route path for leaf menu items, e.g. /reports.'),
1163
+ icon: z.string().optional().describe('Menu icon name.'),
1164
+ type: z.enum(['Menu', 'Dropdown Menu']).optional().default('Menu').describe('Menu type.'),
1165
+ order: z.number().optional().default(0).describe('Display order.'),
1166
+ permission: z.string().optional().describe('Menu permission JSON object.'),
845
1167
  description: z.string().optional().describe('Admin note.'),
1168
+ isEnabled: z.boolean().optional().default(true).describe('Enable menu.'),
846
1169
  },
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 });
1170
+ async (input) => jsonText({
1171
+ action: 'menu_ensured',
1172
+ menu: await ensureMenu(ENFYRA_API_URL, input),
1173
+ }),
1174
+ );
1175
+
1176
+ server.tool(
1177
+ 'ensure_page_extension',
1178
+ 'Business operation: create or update one page extension attached to an existing menu. Validates extension code before save.',
1179
+ {
1180
+ name: z.string().describe('Extension unique name.'),
1181
+ code: z.string().describe('Vue SFC extension code.'),
1182
+ menuId: z.union([z.string(), z.number()]).describe('Existing menu id for this page extension.'),
1183
+ description: z.string().optional().describe('Extension description.'),
1184
+ isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
1185
+ version: z.string().optional().default('1.0.0').describe('Extension version.'),
875
1186
  },
1187
+ async (input) => jsonText({
1188
+ action: 'page_extension_ensured',
1189
+ extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'page' }),
1190
+ }),
876
1191
  );
877
1192
 
878
1193
  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.',
1194
+ 'ensure_global_extension',
1195
+ 'Business operation: create or update one global shell extension. Validates extension code before save and rejects menu coupling.',
881
1196
  {
882
1197
  name: z.string().describe('Extension unique name.'),
883
1198
  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.'),
1199
+ description: z.string().optional().describe('Extension description.'),
1200
+ isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
890
1201
  version: z.string().optional().default('1.0.0').describe('Extension version.'),
891
1202
  },
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
- });
1203
+ async (input) => jsonText({
1204
+ action: 'global_extension_ensured',
1205
+ extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'global' }),
1206
+ }),
1207
+ );
1208
+
1209
+ server.tool(
1210
+ 'ensure_widget_extension',
1211
+ 'Business operation: create or update one widget extension. Validates extension code before save and rejects menu coupling.',
1212
+ {
1213
+ name: z.string().describe('Extension unique name.'),
1214
+ code: z.string().describe('Vue SFC extension code.'),
1215
+ description: z.string().optional().describe('Extension description.'),
1216
+ isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
1217
+ version: z.string().optional().default('1.0.0').describe('Extension version.'),
921
1218
  },
1219
+ async (input) => jsonText({
1220
+ action: 'widget_extension_ensured',
1221
+ extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'widget' }),
1222
+ }),
922
1223
  );
1224
+
923
1225
  }
@@ -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
  // ============================================================================