@enfyra/mcp-server 0.0.97 → 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.
@@ -0,0 +1,1221 @@
1
+ import { z } from 'zod';
2
+
3
+ import { fetchAPI } from './fetch.js';
4
+ import { validateScriptSourceIfPresent } from './mutation-guards.js';
5
+
6
+ function unwrapData(result) {
7
+ return Array.isArray(result?.data) ? result.data : [];
8
+ }
9
+
10
+ function getId(record) {
11
+ return record?.id ?? record?._id ?? null;
12
+ }
13
+
14
+ function refId(value) {
15
+ return typeof value === 'object' && value !== null ? getId(value) : value;
16
+ }
17
+
18
+ function sameId(a, b) {
19
+ if (a === null || a === undefined || b === null || b === undefined) return false;
20
+ return String(a) === String(b);
21
+ }
22
+
23
+ function firstDataRecord(result) {
24
+ return Array.isArray(result?.data) ? result.data[0] : result;
25
+ }
26
+
27
+ function normalizeRestPath(path) {
28
+ if (!path) return '/';
29
+ if (/^https?:\/\//i.test(path)) {
30
+ throw new Error('Only Enfyra API paths are allowed, not full external URLs.');
31
+ }
32
+ return path.startsWith('/') ? path : `/${path}`;
33
+ }
34
+
35
+ function normalizeMethodName(method) {
36
+ const value = String(method || '').trim().toUpperCase();
37
+ if (!/^[A-Z][A-Z0-9_]*$/.test(value)) {
38
+ throw new Error(`Invalid method "${method}". Method names must start with A-Z and contain only A-Z, 0-9, or underscore.`);
39
+ }
40
+ return value;
41
+ }
42
+
43
+ function methodNamesFromRecords(records, methodIdNameMap) {
44
+ return (records || [])
45
+ .map((method) => method?.name || methodIdNameMap[String(getId(method))] || null)
46
+ .filter(Boolean)
47
+ .map(normalizeMethodName);
48
+ }
49
+
50
+ function uniqueMethodNames(names) {
51
+ return [...new Set((names || []).map(normalizeMethodName))];
52
+ }
53
+
54
+ function resolveMethodRefs(methodMap, names) {
55
+ return uniqueMethodNames(names).map((name) => {
56
+ const id = methodMap[name];
57
+ if (!id) throw new Error(`Unknown method "${name}". Valid methods: ${Object.keys(methodMap).sort().join(', ')}`);
58
+ return { id };
59
+ });
60
+ }
61
+
62
+ function mergeMethods(existing, requested, mode) {
63
+ const existingNames = uniqueMethodNames(existing);
64
+ const requestedNames = uniqueMethodNames(requested);
65
+ if (mode === 'replace') return requestedNames;
66
+ if (mode === 'remove') return existingNames.filter((method) => !requestedNames.includes(method));
67
+ return uniqueMethodNames([...existingNames, ...requestedNames]);
68
+ }
69
+
70
+ async function fetchAll(apiUrl, path) {
71
+ return unwrapData(await fetchAPI(apiUrl, path));
72
+ }
73
+
74
+ async function getMethodContext(apiUrl) {
75
+ const methods = await fetchAll(apiUrl, '/enfyra_method?limit=0&fields=id,_id,name');
76
+ const methodMap = {};
77
+ const methodIdNameMap = {};
78
+ for (const method of methods) {
79
+ if (!method?.name) continue;
80
+ const name = normalizeMethodName(method.name);
81
+ const id = getId(method);
82
+ methodMap[name] = id;
83
+ methodIdNameMap[String(id)] = name;
84
+ }
85
+ return { methods, methodMap, methodIdNameMap };
86
+ }
87
+
88
+ async function reloadRoutes(apiUrl) {
89
+ try {
90
+ const result = await fetchAPI(apiUrl, '/admin/reload/routes', { method: 'POST' });
91
+ return { attempted: true, succeeded: true, result };
92
+ } catch (error) {
93
+ return { attempted: true, succeeded: false, error: error?.message || String(error) };
94
+ }
95
+ }
96
+
97
+ async function resolveRoute(apiUrl, { path, routeId }) {
98
+ if (!path && !routeId) throw new Error('Provide path or routeId.');
99
+ if (path && routeId) throw new Error('Provide path or routeId, not both.');
100
+ const routes = await fetchAll(apiUrl, '/enfyra_route?limit=1000&fields=id,_id,path,isEnabled,availableMethods.*,publicMethods.*,mainTable.name');
101
+ const normalizedPath = path ? normalizeRestPath(path) : null;
102
+ const route = routes.find((item) => (routeId ? sameId(getId(item), routeId) : item.path === normalizedPath));
103
+ if (!route) throw new Error(`Route not found: ${routeId || normalizedPath}`);
104
+ return { route, routes, path: route.path };
105
+ }
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
+
167
+ async function findHandler(apiUrl, routeId, methodId) {
168
+ const filter = encodeURIComponent(JSON.stringify({
169
+ route: { id: { _eq: routeId } },
170
+ method: { id: { _eq: methodId } },
171
+ }));
172
+ const result = await fetchAPI(apiUrl, `/enfyra_route_handler?filter=${filter}&limit=1&fields=id,_id,route.id,method.id,method.name,sourceCode,scriptLanguage,timeout`);
173
+ return unwrapData(result)[0] || null;
174
+ }
175
+
176
+ function jsonText(payload) {
177
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
178
+ }
179
+
180
+ function parseJsonObjectArg(name, value, fallback = {}) {
181
+ if (value === undefined || value === null || value === '') return fallback;
182
+ const parsed = typeof value === 'string' ? JSON.parse(value) : value;
183
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
184
+ throw new Error(`${name} must be a JSON object.`);
185
+ }
186
+ return parsed;
187
+ }
188
+
189
+ function parseJsonArrayArg(name, value, fallback = []) {
190
+ if (value === undefined || value === null || value === '') return fallback;
191
+ const parsed = typeof value === 'string' ? JSON.parse(value) : value;
192
+ if (!Array.isArray(parsed)) {
193
+ throw new Error(`${name} must be a JSON array.`);
194
+ }
195
+ return parsed;
196
+ }
197
+
198
+ function filterQuery(filter) {
199
+ return encodeURIComponent(JSON.stringify(filter));
200
+ }
201
+
202
+ async function reloadBestEffort(apiUrl, path) {
203
+ try {
204
+ const result = await fetchAPI(apiUrl, path, { method: 'POST' });
205
+ return { attempted: true, succeeded: true, result };
206
+ } catch (error) {
207
+ return { attempted: true, succeeded: false, error: error?.message || String(error) };
208
+ }
209
+ }
210
+
211
+ async function validateDynamicScript(apiUrl, sourceCode, scriptLanguage = 'javascript') {
212
+ const result = await fetchAPI(apiUrl, '/admin/script/validate', {
213
+ method: 'POST',
214
+ body: JSON.stringify({ sourceCode, scriptLanguage }),
215
+ });
216
+ if (result?.valid === false || result?.success === false) {
217
+ throw new Error(result?.error?.message || 'Dynamic script validation failed.');
218
+ }
219
+ return {
220
+ valid: true,
221
+ scriptLanguage,
222
+ compiledLength: typeof result?.data?.compiledCode === 'string' ? result.data.compiledCode.length : undefined,
223
+ };
224
+ }
225
+
226
+ async function validateExtensionCode(apiUrl, code, name) {
227
+ const result = await fetchAPI(apiUrl, '/enfyra_extension/preview', {
228
+ method: 'POST',
229
+ body: JSON.stringify({ code, name }),
230
+ });
231
+ if (result?.success === false) {
232
+ throw new Error(result?.error?.message || 'Extension validation failed.');
233
+ }
234
+ return {
235
+ valid: true,
236
+ extensionId: result?.extensionId || name || null,
237
+ compiledLength: typeof result?.compiledCode === 'string' ? result.compiledCode.length : undefined,
238
+ };
239
+ }
240
+
241
+ function normalizeMetadataTables(metadata) {
242
+ const tables = metadata?.data?.tables || metadata?.tables || metadata?.data || [];
243
+ return Array.isArray(tables) ? tables : Object.values(tables || {});
244
+ }
245
+
246
+ async function getMetadataTables(apiUrl) {
247
+ return normalizeMetadataTables(await fetchAPI(apiUrl, '/metadata'));
248
+ }
249
+
250
+ function resolveTable(tables, tableName) {
251
+ const table = tables.find((item) => item?.name === tableName || item?.alias === tableName || sameId(getId(item), tableName));
252
+ if (!table) throw new Error(`Table not found: ${tableName}`);
253
+ return table;
254
+ }
255
+
256
+ function resolveColumn(table, columnName) {
257
+ const column = (table.columns || []).find((item) => item?.name === columnName || sameId(getId(item), columnName));
258
+ if (!column) throw new Error(`Column not found: ${table.name}.${columnName}`);
259
+ return column;
260
+ }
261
+
262
+ function resolveRelation(table, relationName) {
263
+ const relation = (table.relations || []).find((item) => item?.propertyName === relationName || item?.name === relationName || sameId(getId(item), relationName));
264
+ if (!relation) throw new Error(`Relation not found: ${table.name}.${relationName}`);
265
+ return relation;
266
+ }
267
+
268
+ async function findRecord(apiUrl, tableName, filter, fields = '*') {
269
+ const result = await fetchAPI(apiUrl, `/${tableName}?filter=${filterQuery(filter)}&limit=1&fields=${encodeURIComponent(fields)}`);
270
+ return unwrapData(result)[0] || null;
271
+ }
272
+
273
+ async function fetchRecords(apiUrl, tableName, filter, fields = '*', limit = 1000) {
274
+ const result = await fetchAPI(apiUrl, `/${tableName}?filter=${filterQuery(filter)}&limit=${limit}&fields=${encodeURIComponent(fields)}`);
275
+ return unwrapData(result);
276
+ }
277
+
278
+ async function createOrPatch(apiUrl, tableName, existing, body) {
279
+ if (existing) {
280
+ const result = await fetchAPI(apiUrl, `/${tableName}/${encodeURIComponent(String(getId(existing)))}`, {
281
+ method: 'PATCH',
282
+ body: JSON.stringify(body),
283
+ });
284
+ return { action: 'updated', result, id: getId(firstDataRecord(result)) || getId(existing) };
285
+ }
286
+ const result = await fetchAPI(apiUrl, `/${tableName}`, {
287
+ method: 'POST',
288
+ body: JSON.stringify(body),
289
+ });
290
+ return { action: 'created', result, id: getId(firstDataRecord(result)) };
291
+ }
292
+
293
+ async function resolveRole(apiUrl, { roleId, roleName }) {
294
+ if (roleId && roleName) throw new Error('Provide roleId or roleName, not both.');
295
+ if (!roleId && !roleName) return null;
296
+ if (roleId) return { id: roleId, name: null };
297
+ const role = await findRecord(apiUrl, 'enfyra_role', { name: { _eq: roleName } }, 'id,_id,name');
298
+ if (!role) throw new Error(`Role not found: ${roleName}`);
299
+ return { id: getId(role), name: role.name };
300
+ }
301
+
302
+ function assertOneScope({ roleId, roleName, allowedUserIds }) {
303
+ if (!roleId && !roleName && (!allowedUserIds || allowedUserIds.length === 0)) {
304
+ throw new Error('Provide roleId, roleName, or allowedUserIds.');
305
+ }
306
+ }
307
+
308
+ function normalizeFlowStepBody(step, flowId) {
309
+ const body = {
310
+ key: step.key,
311
+ type: step.type,
312
+ stepOrder: step.order ?? 0,
313
+ config: step.config ?? {},
314
+ timeout: step.timeout,
315
+ isEnabled: step.isEnabled ?? true,
316
+ flow: { id: flowId },
317
+ };
318
+ if (step.sourceCode !== undefined) body.sourceCode = step.sourceCode;
319
+ if (step.scriptLanguage !== undefined) body.scriptLanguage = step.scriptLanguage;
320
+ return Object.fromEntries(Object.entries(body).filter(([, value]) => value !== undefined));
321
+ }
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
+
455
+ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
456
+ server.tool(
457
+ 'validate_dynamic_script',
458
+ [
459
+ 'Validate Enfyra dynamic script code before saving it to any script-backed metadata record.',
460
+ 'Use this before create/update of handlers, hooks, flow steps, websocket scripts, GraphQL scripts, or bootstrap scripts when the user is iterating on code.',
461
+ 'This calls the same server compiler contract used by Enfyra, but does not save anything.',
462
+ ].join(' '),
463
+ {
464
+ sourceCode: z.string().describe('Raw dynamic script sourceCode.'),
465
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language to validate.'),
466
+ },
467
+ async ({ sourceCode, scriptLanguage }) => jsonText({
468
+ action: 'dynamic_script_validated',
469
+ validation: await validateDynamicScript(ENFYRA_API_URL, sourceCode, scriptLanguage),
470
+ }),
471
+ );
472
+
473
+ server.tool(
474
+ 'validate_extension_code',
475
+ [
476
+ 'Validate Enfyra admin extension code before saving it to enfyra_extension.',
477
+ 'Use this for Vue SFC page/widget/global extension code. It calls /enfyra_extension/preview and does not save anything.',
478
+ ].join(' '),
479
+ {
480
+ code: z.string().describe('Vue SFC or compiled extension bundle code.'),
481
+ name: z.string().optional().describe('Optional extension name/id used by the preview compiler.'),
482
+ },
483
+ async ({ code, name }) => jsonText({
484
+ action: 'extension_code_validated',
485
+ validation: await validateExtensionCode(ENFYRA_API_URL, code, name),
486
+ }),
487
+ );
488
+
489
+ server.tool(
490
+ 'set_table_graphql',
491
+ 'Business operation: enable or disable GraphQL for one table through enfyra_graphql, then reload GraphQL. REST route methods do not control GraphQL.',
492
+ {
493
+ tableName: z.string().describe('Table name, alias, or id.'),
494
+ isEnabled: z.boolean().describe('Desired GraphQL enabled state for the table.'),
495
+ },
496
+ async ({ tableName, isEnabled }) => {
497
+ const table = resolveTable(await getMetadataTables(ENFYRA_API_URL), tableName);
498
+ const existing = await findRecord(ENFYRA_API_URL, 'enfyra_graphql', { table: { id: { _eq: getId(table) } } }, 'id,_id,table.id,isEnabled');
499
+ const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_graphql', existing, {
500
+ table: { id: getId(table) },
501
+ isEnabled,
502
+ });
503
+ const graphqlReload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/graphql');
504
+ return jsonText({
505
+ action: 'table_graphql_set',
506
+ table: { id: getId(table), name: table.name },
507
+ graphql: { id: operation.id, isEnabled },
508
+ operation,
509
+ graphqlReload,
510
+ });
511
+ },
512
+ );
513
+
514
+ server.tool(
515
+ 'add_route_methods',
516
+ 'Business operation: add HTTP methods to an existing route.',
517
+ {
518
+ path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
519
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
520
+ methods: z.array(z.string()).min(1).describe('HTTP method names to add.'),
521
+ isEnabled: z.boolean().optional().describe('Optionally enable/disable the route in the same safe patch.'),
522
+ },
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
+ );
531
+
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.'),
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
+ })),
548
+ );
549
+
550
+ server.tool(
551
+ 'remove_route_methods',
552
+ 'Business operation: remove HTTP methods from an existing route.',
553
+ {
554
+ path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
555
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
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.'),
558
+ },
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.'),
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
+ })),
614
+ );
615
+
616
+ server.tool(
617
+ 'create_api_endpoint',
618
+ [
619
+ 'Business operation: create or update a custom REST endpoint with a handler in one safe operation.',
620
+ 'Use this when the user asks for a new route/endpoint/API path that computes or orchestrates behavior, such as GET /sum or POST /webhook.',
621
+ 'It creates the route without mainTableId, ensures the method is available, validates sourceCode, creates or overwrites the route handler, optionally publishes the method, reloads routes, and can smoke-test the endpoint.',
622
+ 'Use table/schema tools separately when the user needs persisted data. This tool is for custom behavior endpoints.',
623
+ ].join(' '),
624
+ {
625
+ path: z.string().describe('Custom route path, e.g. /sum. Must not be a full URL.'),
626
+ method: z.string().describe('HTTP method for the handler, e.g. GET or POST.'),
627
+ sourceCode: z.string().describe('Handler sourceCode. Use macros such as @QUERY, @BODY, @THROW400, @REPOS, @USER. Do not send compiledCode.'),
628
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
629
+ public: z.boolean().optional().default(false).describe('When true, the method is added to publicMethods for anonymous access.'),
630
+ description: z.string().optional().describe('Route description.'),
631
+ timeout: z.number().int().positive().optional().describe('Optional handler timeout in ms.'),
632
+ overwrite: z.boolean().optional().default(false).describe('If a handler already exists for route+method, false fails; true updates its sourceCode.'),
633
+ smokeTestQuery: z.string().optional().describe('Optional query JSON object for a smoke test after save, e.g. {"a":"1","b":"2"}.'),
634
+ smokeTestBody: z.string().optional().describe('Optional body JSON object for a smoke test after save.'),
635
+ },
636
+ async ({ path, method, sourceCode, scriptLanguage, public: makePublic, description, timeout, overwrite, smokeTestQuery, smokeTestBody }) => {
637
+ const normalizedPath = normalizeRestPath(path);
638
+ const methodName = normalizeMethodName(method);
639
+ const { methodMap, methodIdNameMap } = await getMethodContext(ENFYRA_API_URL);
640
+ const methodId = methodMap[methodName];
641
+ if (!methodId) throw new Error(`Unknown method "${methodName}". Valid methods: ${Object.keys(methodMap).sort().join(', ')}`);
642
+
643
+ const routes = await fetchAll(ENFYRA_API_URL, '/enfyra_route?limit=1000&fields=id,_id,path,isEnabled,availableMethods.*,publicMethods.*,mainTable.name');
644
+ let route = routes.find((item) => item.path === normalizedPath);
645
+ let routeAction = 'existing';
646
+ if (!route) {
647
+ const createRouteResult = await fetchAPI(ENFYRA_API_URL, '/enfyra_route', {
648
+ method: 'POST',
649
+ body: JSON.stringify({
650
+ path: normalizedPath,
651
+ description,
652
+ isEnabled: true,
653
+ availableMethods: [{ id: methodId }],
654
+ publicMethods: makePublic ? [{ id: methodId }] : [],
655
+ }),
656
+ });
657
+ route = firstDataRecord(createRouteResult);
658
+ routeAction = 'created';
659
+ } else {
660
+ const availableMethods = methodNamesFromRecords(route.availableMethods, methodIdNameMap);
661
+ const publicMethods = methodNamesFromRecords(route.publicMethods, methodIdNameMap);
662
+ const finalAvailable = uniqueMethodNames([...availableMethods, methodName]);
663
+ const finalPublic = makePublic ? uniqueMethodNames([...publicMethods, methodName]) : publicMethods;
664
+ const patchRouteResult = await fetchAPI(ENFYRA_API_URL, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, {
665
+ method: 'PATCH',
666
+ body: JSON.stringify({
667
+ availableMethods: resolveMethodRefs(methodMap, finalAvailable),
668
+ publicMethods: resolveMethodRefs(methodMap, finalPublic.filter((item) => finalAvailable.includes(item))),
669
+ ...(description !== undefined ? { description } : {}),
670
+ }),
671
+ });
672
+ route = firstDataRecord(patchRouteResult) || route;
673
+ routeAction = 'updated';
674
+ }
675
+
676
+ const routeId = getId(route);
677
+ const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'enfyra_route_handler', {
678
+ sourceCode,
679
+ scriptLanguage,
680
+ });
681
+ const existingHandler = await findHandler(ENFYRA_API_URL, routeId, methodId);
682
+ let handlerResult;
683
+ let handlerAction;
684
+ if (existingHandler) {
685
+ if (!overwrite) {
686
+ throw new Error(`Handler already exists for ${methodName} ${normalizedPath} with id ${getId(existingHandler)}. Re-run with overwrite=true to update it.`);
687
+ }
688
+ handlerAction = 'updated';
689
+ const body = { sourceCode, scriptLanguage };
690
+ if (timeout !== undefined) body.timeout = timeout;
691
+ handlerResult = await fetchAPI(ENFYRA_API_URL, `/enfyra_route_handler/${encodeURIComponent(String(getId(existingHandler)))}`, {
692
+ method: 'PATCH',
693
+ body: JSON.stringify(body),
694
+ });
695
+ } else {
696
+ handlerAction = 'created';
697
+ const body = {
698
+ route: { id: routeId },
699
+ method: { id: methodId },
700
+ sourceCode,
701
+ scriptLanguage,
702
+ };
703
+ if (timeout !== undefined) body.timeout = timeout;
704
+ handlerResult = await fetchAPI(ENFYRA_API_URL, '/enfyra_route_handler', {
705
+ method: 'POST',
706
+ body: JSON.stringify(body),
707
+ });
708
+ }
709
+
710
+ const routeReload = await reloadRoutes(ENFYRA_API_URL);
711
+ let smokeTest = null;
712
+ if (smokeTestQuery !== undefined || smokeTestBody !== undefined) {
713
+ const query = smokeTestQuery ? JSON.parse(smokeTestQuery) : {};
714
+ if (!query || typeof query !== 'object' || Array.isArray(query)) throw new Error('smokeTestQuery must be a JSON object.');
715
+ const queryParams = new URLSearchParams();
716
+ for (const [key, value] of Object.entries(query)) {
717
+ if (value !== undefined && value !== null) queryParams.set(key, String(value));
718
+ }
719
+ const body = smokeTestBody ? JSON.parse(smokeTestBody) : undefined;
720
+ const smokePath = `${normalizedPath}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
721
+ smokeTest = await fetchAPI(ENFYRA_API_URL, smokePath, {
722
+ method: methodName,
723
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
724
+ });
725
+ }
726
+
727
+ const savedHandler = firstDataRecord(handlerResult);
728
+ return {
729
+ content: [{
730
+ type: 'text',
731
+ text: JSON.stringify({
732
+ action: 'api_endpoint_ready',
733
+ endpoint: {
734
+ path: normalizedPath,
735
+ method: methodName,
736
+ public: makePublic,
737
+ routeId,
738
+ handlerId: getId(savedHandler) || getId(existingHandler),
739
+ },
740
+ routeAction,
741
+ handlerAction,
742
+ scriptValidation,
743
+ routeReload,
744
+ smokeTest,
745
+ usage: {
746
+ restPath: `${ENFYRA_API_URL.replace(/\/$/, '')}${normalizedPath}`,
747
+ auth: makePublic ? 'anonymous allowed for this method' : 'Bearer auth and route access are required unless another guard bypass applies',
748
+ },
749
+ }, null, 2),
750
+ }],
751
+ };
752
+ },
753
+ );
754
+
755
+ server.tool(
756
+ 'ensure_column_rule',
757
+ 'Business operation: create or update a column validation rule. It resolves table/column ids and avoids duplicate rules for the same column+ruleType.',
758
+ {
759
+ tableName: z.string().describe('Table name, alias, or id.'),
760
+ columnName: z.string().describe('Column name or id.'),
761
+ ruleType: z.enum(['min', 'max', 'minLength', 'maxLength', 'pattern', 'format', 'minItems', 'maxItems', 'custom']).describe('Validation rule type.'),
762
+ value: z.string().optional().describe('Rule config JSON object, usually {"v": ...}.'),
763
+ message: z.string().optional().describe('Custom validation error message.'),
764
+ description: z.string().optional().describe('Admin note.'),
765
+ isEnabled: z.boolean().optional().default(true).describe('Enable the rule.'),
766
+ },
767
+ async ({ tableName, columnName, ruleType, value, message, description, isEnabled }) => {
768
+ const table = resolveTable(await getMetadataTables(ENFYRA_API_URL), tableName);
769
+ const column = resolveColumn(table, columnName);
770
+ const existing = await findRecord(ENFYRA_API_URL, 'enfyra_column_rule', {
771
+ column: { id: { _eq: getId(column) } },
772
+ ruleType: { _eq: ruleType },
773
+ }, 'id,_id,column.id,ruleType');
774
+ const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_column_rule', existing, {
775
+ column: { id: getId(column) },
776
+ ruleType,
777
+ value: parseJsonObjectArg('value', value, null),
778
+ message,
779
+ description,
780
+ isEnabled,
781
+ });
782
+ return jsonText({
783
+ action: 'column_rule_ensured',
784
+ table: { id: getId(table), name: table.name },
785
+ column: { id: getId(column), name: column.name },
786
+ ruleType,
787
+ operation,
788
+ });
789
+ },
790
+ );
791
+
792
+ server.tool(
793
+ 'ensure_field_permission',
794
+ 'Business operation: create or update one field permission. It resolves table field ids, enforces exactly one column/relation target, and enforces a role/user scope.',
795
+ {
796
+ tableName: z.string().describe('Table name, alias, or id.'),
797
+ columnName: z.string().optional().describe('Column name/id to protect. Use exactly one of columnName or relationName.'),
798
+ relationName: z.string().optional().describe('Relation propertyName/id to protect. Use exactly one of columnName or relationName.'),
799
+ action: z.enum(['read', 'create', 'update']).optional().default('read').describe('Field action.'),
800
+ effect: z.enum(['allow', 'deny']).optional().default('allow').describe('Permission effect.'),
801
+ roleId: z.union([z.string(), z.number()]).optional().describe('Role id scope.'),
802
+ roleName: z.string().optional().describe('Role name scope.'),
803
+ allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Direct user id scope.'),
804
+ condition: z.string().optional().describe('Condition JSON object using field permission DSL.'),
805
+ description: z.string().optional().describe('Admin note.'),
806
+ isEnabled: z.boolean().optional().default(true).describe('Enable the permission.'),
807
+ },
808
+ async ({ tableName, columnName, relationName, action, effect, roleId, roleName, allowedUserIds, condition, description, isEnabled }) => {
809
+ if (!!columnName === !!relationName) throw new Error('Provide exactly one of columnName or relationName.');
810
+ assertOneScope({ roleId, roleName, allowedUserIds });
811
+ const [tables, role] = await Promise.all([
812
+ getMetadataTables(ENFYRA_API_URL),
813
+ resolveRole(ENFYRA_API_URL, { roleId, roleName }),
814
+ ]);
815
+ const table = resolveTable(tables, tableName);
816
+ const field = columnName ? resolveColumn(table, columnName) : resolveRelation(table, relationName);
817
+ const filter = {
818
+ action: { _eq: action },
819
+ effect: { _eq: effect },
820
+ ...(columnName ? { column: { id: { _eq: getId(field) } } } : { relation: { id: { _eq: getId(field) } } }),
821
+ ...(role ? { role: { id: { _eq: role.id } } } : {}),
822
+ };
823
+ const existing = role
824
+ ? await findRecord(ENFYRA_API_URL, 'enfyra_field_permission', filter, 'id,_id,column.id,relation.id,role.id,action,effect')
825
+ : null;
826
+ const body = {
827
+ action,
828
+ effect,
829
+ isEnabled,
830
+ description,
831
+ condition: parseJsonObjectArg('condition', condition, null),
832
+ ...(columnName ? { column: { id: getId(field) } } : { relation: { id: getId(field) } }),
833
+ ...(role ? { role: { id: role.id } } : {}),
834
+ ...(allowedUserIds?.length ? { allowedUsers: allowedUserIds.map((id) => ({ id })) } : {}),
835
+ };
836
+ const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_field_permission', existing, body);
837
+ const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/metadata');
838
+ return jsonText({
839
+ action: 'field_permission_ensured',
840
+ table: { id: getId(table), name: table.name },
841
+ field: { id: getId(field), name: columnName ? field.name : field.propertyName, kind: columnName ? 'column' : 'relation' },
842
+ scope: { role, allowedUserIds: allowedUserIds || [] },
843
+ operation,
844
+ reload,
845
+ });
846
+ },
847
+ );
848
+
849
+ server.tool(
850
+ 'ensure_guard',
851
+ 'Business operation: create or update a request guard and optional guard rules. It resolves route/method ids and prevents pre_auth user-based rules.',
852
+ {
853
+ name: z.string().describe('Guard name. Existing guard with this name is updated unless guardId is provided.'),
854
+ guardId: z.union([z.string(), z.number()]).optional().describe('Optional existing guard id.'),
855
+ position: z.enum(['pre_auth', 'post_auth']).optional().default('pre_auth').describe('Guard position.'),
856
+ routeId: z.union([z.string(), z.number()]).optional().describe('Optional route id.'),
857
+ path: z.string().optional().describe('Optional route path.'),
858
+ methods: z.array(z.string()).optional().describe('HTTP method names.'),
859
+ combinator: z.enum(['and', 'or']).optional().default('and').describe('Rule combinator.'),
860
+ priority: z.number().optional().default(0).describe('Lower runs earlier.'),
861
+ isGlobal: z.boolean().optional().default(false).describe('Apply globally.'),
862
+ isEnabled: z.boolean().optional().default(false).describe('Enable guard. Defaults false to avoid lockout.'),
863
+ description: z.string().optional().describe('Admin note.'),
864
+ rules: z.string().optional().describe('Rules JSON array: [{type, config, priority, isEnabled, description, userIds}].'),
865
+ rulesMode: z.enum(['append', 'replace', 'none']).optional().default('append').describe('append creates rules, replace disables existing rules first, none leaves rules unchanged.'),
866
+ },
867
+ async ({ name, guardId, position, routeId, path, methods, combinator, priority, isGlobal, isEnabled, description, rules, rulesMode }) => {
868
+ if (path && routeId) throw new Error('Provide path or routeId, not both.');
869
+ const ruleInputs = parseJsonArrayArg('rules', rules, []);
870
+ if (position === 'pre_auth') {
871
+ const invalid = ruleInputs.filter((rule) => rule.type === 'rate_limit_by_user' || (Array.isArray(rule.userIds) && rule.userIds.length));
872
+ if (invalid.length) throw new Error('pre_auth guards cannot use user-based rules or userIds. Use post_auth.');
873
+ }
874
+ let route = null;
875
+ if (!isGlobal && (routeId || path)) {
876
+ route = (await resolveRoute(ENFYRA_API_URL, { path, routeId })).route;
877
+ }
878
+ const { methodMap } = await getMethodContext(ENFYRA_API_URL);
879
+ const existing = guardId
880
+ ? await findRecord(ENFYRA_API_URL, 'enfyra_guard', { id: { _eq: guardId } }, 'id,_id,name')
881
+ : await findRecord(ENFYRA_API_URL, 'enfyra_guard', { name: { _eq: name } }, 'id,_id,name');
882
+ const guardBody = {
883
+ name,
884
+ position,
885
+ combinator,
886
+ priority,
887
+ isGlobal,
888
+ isEnabled,
889
+ description,
890
+ ...(route ? { route: { id: getId(route) } } : {}),
891
+ ...(methods?.length ? { methods: resolveMethodRefs(methodMap, methods) } : {}),
892
+ };
893
+ const guardOperation = await createOrPatch(ENFYRA_API_URL, 'enfyra_guard', existing, guardBody);
894
+ const resolvedGuardId = guardOperation.id || getId(existing);
895
+ const existingRules = rulesMode === 'replace'
896
+ ? await fetchRecords(ENFYRA_API_URL, 'enfyra_guard_rule', { guard: { id: { _eq: resolvedGuardId } } }, 'id,_id,isEnabled')
897
+ : [];
898
+ const disabledRules = [];
899
+ for (const rule of existingRules) {
900
+ disabledRules.push(await fetchAPI(ENFYRA_API_URL, `/enfyra_guard_rule/${encodeURIComponent(String(getId(rule)))}`, {
901
+ method: 'PATCH',
902
+ body: JSON.stringify({ isEnabled: false }),
903
+ }));
904
+ }
905
+ const createdRules = [];
906
+ if (rulesMode !== 'none') {
907
+ for (const rule of ruleInputs) {
908
+ createdRules.push(await fetchAPI(ENFYRA_API_URL, '/enfyra_guard_rule', {
909
+ method: 'POST',
910
+ body: JSON.stringify({
911
+ type: rule.type,
912
+ config: rule.config,
913
+ priority: rule.priority ?? 0,
914
+ isEnabled: rule.isEnabled ?? true,
915
+ description: rule.description,
916
+ guard: { id: resolvedGuardId },
917
+ ...(Array.isArray(rule.userIds) && rule.userIds.length ? { users: rule.userIds.map((id) => ({ id })) } : {}),
918
+ }),
919
+ }));
920
+ }
921
+ }
922
+ const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/guards');
923
+ return jsonText({
924
+ action: 'guard_ensured',
925
+ guard: { id: resolvedGuardId, name, route: route ? route.path : null, isGlobal },
926
+ guardOperation,
927
+ disabledRuleCount: disabledRules.length,
928
+ createdRuleCount: createdRules.length,
929
+ reload,
930
+ });
931
+ },
932
+ );
933
+
934
+ server.tool(
935
+ 'ensure_websocket_gateway',
936
+ 'Business operation: create or update an Enfyra Socket.IO gateway. Connection handler sourceCode is validated before save.',
937
+ {
938
+ path: z.string().describe('Gateway namespace/path, e.g. /chat.'),
939
+ sourceCode: z.string().optional().describe('Optional connection handler dynamic script sourceCode.'),
940
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for connection handler.'),
941
+ isEnabled: z.boolean().optional().default(true).describe('Enable gateway.'),
942
+ description: z.string().optional().describe('Admin note.'),
943
+ },
944
+ async ({ path, sourceCode, scriptLanguage, isEnabled, description }) => {
945
+ const normalizedPath = normalizeRestPath(path);
946
+ const validation = sourceCode === undefined
947
+ ? { validated: false, reason: 'no sourceCode' }
948
+ : await validateDynamicScript(ENFYRA_API_URL, sourceCode, scriptLanguage);
949
+ const existing = await findRecord(ENFYRA_API_URL, 'enfyra_websocket', { path: { _eq: normalizedPath } }, 'id,_id,path');
950
+ const body = {
951
+ path: normalizedPath,
952
+ isEnabled,
953
+ description,
954
+ ...(sourceCode !== undefined ? { sourceCode, scriptLanguage } : {}),
955
+ };
956
+ const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_websocket', existing, body);
957
+ const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/websockets');
958
+ return jsonText({ action: 'websocket_gateway_ensured', gateway: { id: operation.id, path: normalizedPath }, validation, operation, reload });
959
+ },
960
+ );
961
+
962
+ server.tool(
963
+ 'ensure_websocket_event',
964
+ 'Business operation: create or update one websocket event handler. It resolves gateway path/id and validates sourceCode before save.',
965
+ {
966
+ gatewayPath: z.string().optional().describe('Gateway path, e.g. /chat. Use gatewayPath or gatewayId.'),
967
+ gatewayId: z.union([z.string(), z.number()]).optional().describe('Gateway id. Use gatewayPath or gatewayId.'),
968
+ eventName: z.string().describe('Socket event name.'),
969
+ sourceCode: z.string().describe('Event handler dynamic script sourceCode.'),
970
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
971
+ isEnabled: z.boolean().optional().default(true).describe('Enable event.'),
972
+ description: z.string().optional().describe('Admin note.'),
973
+ },
974
+ async ({ gatewayPath, gatewayId, eventName, sourceCode, scriptLanguage, isEnabled, description }) => {
975
+ if (!gatewayPath && !gatewayId) throw new Error('Provide gatewayPath or gatewayId.');
976
+ if (gatewayPath && gatewayId) throw new Error('Provide gatewayPath or gatewayId, not both.');
977
+ const gateway = gatewayId
978
+ ? await findRecord(ENFYRA_API_URL, 'enfyra_websocket', { id: { _eq: gatewayId } }, 'id,_id,path')
979
+ : await findRecord(ENFYRA_API_URL, 'enfyra_websocket', { path: { _eq: normalizeRestPath(gatewayPath) } }, 'id,_id,path');
980
+ if (!gateway) throw new Error(`Websocket gateway not found: ${gatewayId || gatewayPath}`);
981
+ const validation = await validateDynamicScript(ENFYRA_API_URL, sourceCode, scriptLanguage);
982
+ const existing = await findRecord(ENFYRA_API_URL, 'enfyra_websocket_event', {
983
+ gateway: { id: { _eq: getId(gateway) } },
984
+ eventName: { _eq: eventName },
985
+ }, 'id,_id,eventName,gateway.id');
986
+ const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_websocket_event', existing, {
987
+ gateway: { id: getId(gateway) },
988
+ eventName,
989
+ sourceCode,
990
+ scriptLanguage,
991
+ isEnabled,
992
+ description,
993
+ });
994
+ const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/websockets');
995
+ return jsonText({ action: 'websocket_event_ensured', gateway: { id: getId(gateway), path: gateway.path }, eventName, validation, operation, reload });
996
+ },
997
+ );
998
+
999
+ server.tool(
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.',
1002
+ {
1003
+ name: z.string().describe('Flow name. Existing flow with this name is updated.'),
1004
+ timeout: z.number().int().positive().optional().describe('Flow timeout in ms.'),
1005
+ maxExecutions: z.number().int().positive().optional().default(100).describe('Execution history cap.'),
1006
+ isEnabled: z.boolean().optional().default(true).describe('Enable flow.'),
1007
+ description: z.string().optional().describe('Admin note.'),
1008
+ },
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.'),
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
+ })),
1039
+ );
1040
+
1041
+ server.tool(
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.',
1044
+ {
1045
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
1046
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
1047
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
1048
+ sourceCode: z.string().describe('Script sourceCode.'),
1049
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
1050
+ config: z.string().optional().describe('Step config JSON object.'),
1051
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
1052
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
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.'),
1163
+ description: z.string().optional().describe('Admin note.'),
1164
+ isEnabled: z.boolean().optional().default(true).describe('Enable menu.'),
1165
+ },
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.'),
1182
+ },
1183
+ async (input) => jsonText({
1184
+ action: 'page_extension_ensured',
1185
+ extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'page' }),
1186
+ }),
1187
+ );
1188
+
1189
+ server.tool(
1190
+ 'ensure_global_extension',
1191
+ 'Business operation: create or update one global shell extension. Validates extension code before save and rejects menu coupling.',
1192
+ {
1193
+ name: z.string().describe('Extension unique name.'),
1194
+ code: z.string().describe('Vue SFC extension code.'),
1195
+ description: z.string().optional().describe('Extension description.'),
1196
+ isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
1197
+ version: z.string().optional().default('1.0.0').describe('Extension version.'),
1198
+ },
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.'),
1214
+ },
1215
+ async (input) => jsonText({
1216
+ action: 'widget_extension_ensured',
1217
+ extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'widget' }),
1218
+ }),
1219
+ );
1220
+
1221
+ }