@enfyra/mcp-server 0.1.13 → 0.1.14

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.
Files changed (68) hide show
  1. package/README.md +14 -1
  2. package/dist/index.d.ts +5 -0
  3. package/{src/index.mjs → dist/index.js} +8 -10
  4. package/dist/index.js.map +1 -0
  5. package/dist/lib/auth.d.ts +34 -0
  6. package/dist/lib/auth.js +161 -0
  7. package/dist/lib/auth.js.map +1 -0
  8. package/dist/lib/config-local.d.ts +1 -0
  9. package/dist/lib/config-local.js +719 -0
  10. package/dist/lib/config-local.js.map +1 -0
  11. package/dist/lib/fetch.d.ts +28 -0
  12. package/dist/lib/fetch.js +106 -0
  13. package/dist/lib/fetch.js.map +1 -0
  14. package/dist/lib/mcp-examples.d.ts +99 -0
  15. package/dist/lib/mcp-examples.js +2285 -0
  16. package/dist/lib/mcp-examples.js.map +1 -0
  17. package/dist/lib/mcp-instructions.d.ts +11 -0
  18. package/dist/lib/mcp-instructions.js +78 -0
  19. package/dist/lib/mcp-instructions.js.map +1 -0
  20. package/dist/lib/mutation-guards.d.ts +33 -0
  21. package/dist/lib/mutation-guards.js +106 -0
  22. package/dist/lib/mutation-guards.js.map +1 -0
  23. package/dist/lib/platform-operation-tools.d.ts +12 -0
  24. package/dist/lib/platform-operation-tools.js +2304 -0
  25. package/dist/lib/platform-operation-tools.js.map +1 -0
  26. package/dist/lib/required-knowledge.d.ts +32 -0
  27. package/dist/lib/required-knowledge.js +181 -0
  28. package/dist/lib/required-knowledge.js.map +1 -0
  29. package/dist/lib/response-format.d.ts +7 -0
  30. package/dist/lib/response-format.js +179 -0
  31. package/dist/lib/response-format.js.map +1 -0
  32. package/dist/lib/route-guards.d.ts +1 -0
  33. package/dist/lib/route-guards.js +19 -0
  34. package/dist/lib/route-guards.js.map +1 -0
  35. package/dist/lib/route-permission-tools.d.ts +91 -0
  36. package/dist/lib/route-permission-tools.js +151 -0
  37. package/dist/lib/route-permission-tools.js.map +1 -0
  38. package/dist/lib/source-artifacts.d.ts +27 -0
  39. package/dist/lib/source-artifacts.js +82 -0
  40. package/dist/lib/source-artifacts.js.map +1 -0
  41. package/dist/lib/table-tools.d.ts +62 -0
  42. package/dist/lib/table-tools.js +774 -0
  43. package/dist/lib/table-tools.js.map +1 -0
  44. package/dist/lib/tool-routing.d.ts +297 -0
  45. package/dist/lib/tool-routing.js +585 -0
  46. package/dist/lib/tool-routing.js.map +1 -0
  47. package/dist/lib/types.d.ts +17 -0
  48. package/dist/lib/types.js +2 -0
  49. package/dist/lib/types.js.map +1 -0
  50. package/dist/mcp-server-entry.d.ts +4 -0
  51. package/dist/mcp-server-entry.js +2785 -0
  52. package/dist/mcp-server-entry.js.map +1 -0
  53. package/package.json +16 -9
  54. package/src/lib/auth.js +0 -179
  55. package/src/lib/config-local.mjs +0 -718
  56. package/src/lib/fetch.js +0 -111
  57. package/src/lib/mcp-examples.js +0 -2289
  58. package/src/lib/mcp-instructions.js +0 -80
  59. package/src/lib/mutation-guards.js +0 -118
  60. package/src/lib/platform-operation-tools.js +0 -2616
  61. package/src/lib/required-knowledge.js +0 -188
  62. package/src/lib/response-format.js +0 -187
  63. package/src/lib/route-guards.js +0 -24
  64. package/src/lib/route-permission-tools.js +0 -160
  65. package/src/lib/source-artifacts.js +0 -82
  66. package/src/lib/table-tools.js +0 -907
  67. package/src/lib/tool-routing.js +0 -589
  68. package/src/mcp-server-entry.mjs +0 -3177
@@ -1,3177 +0,0 @@
1
- /**
2
- * Enfyra MCP — stdio server (loaded by index.mjs).
3
- */
4
-
5
- import { config } from 'dotenv';
6
- config();
7
-
8
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
- import { z } from 'zod';
11
- import { createHash } from 'node:crypto';
12
-
13
- // Configuration
14
- const ENFYRA_API_URL = process.env.ENFYRA_API_URL || 'http://localhost:3000/api';
15
- const ENFYRA_API_TOKEN = process.env.ENFYRA_API_TOKEN || '';
16
- const DISCOVERY_FETCH_TIMEOUT_MS = 12000;
17
-
18
- // Import modules
19
- import { exchangeApiToken, refreshAccessToken, getValidToken, resetTokens, getTokenExpiry, initAuth } from './lib/auth.js';
20
- import { fetchAPI, validateFilter, validateTableName } from './lib/fetch.js';
21
- import { buildMcpServerInstructions, buildGraphqlUrls } from './lib/mcp-instructions.js';
22
- import { getExamples, listExampleCategories } from './lib/mcp-examples.js';
23
- import { WORKFLOW_SURFACES, discoverWorkflowRoutes } from './lib/tool-routing.js';
24
- import { registerTableTools } from './lib/table-tools.js';
25
- import { registerPlatformOperationTools, validateExtensionCode } from './lib/platform-operation-tools.js';
26
- import { parseRecordData, prepareRecordMutation, validateScriptSourceIfPresent } from './lib/mutation-guards.js';
27
- import {
28
- assertDynamicCodeKnowledgeAck,
29
- assertDynamicCodeKnowledgeAckIf,
30
- assertExtensionKnowledgeAckIf,
31
- assertGlobalRulesAck,
32
- buildRequiredKnowledgePayload,
33
- dynamicCodeKnowledgeAckParam,
34
- extensionKnowledgeAckParam,
35
- globalRulesAckParam,
36
- } from './lib/required-knowledge.js';
37
- import { validateMainTableRoutePath } from './lib/route-guards.js';
38
- import { installColumnarToolFormatter, jsonContent } from './lib/response-format.js';
39
- import { compactSourceFields, writeSourceArtifact } from './lib/source-artifacts.js';
40
- import {
41
- findRoutePermission,
42
- mergeMethodNames,
43
- normalizeMethodNames,
44
- resolveRoleByNameOrId,
45
- routeAvailableMethodNames,
46
- routePublicMethodNames,
47
- summarizeRouteAccess,
48
- summarizeRoutePermission,
49
- validateMethodsForRoute,
50
- } from './lib/route-permission-tools.js';
51
-
52
- // Initialize auth module
53
- initAuth(ENFYRA_API_URL, ENFYRA_API_TOKEN);
54
-
55
- const CAPABILITY_AREAS = [
56
- {
57
- area: 'Schema and metadata',
58
- tables: ['enfyra_table', 'enfyra_column', 'enfyra_relation', 'enfyra_schema_migration'],
59
- workflow: 'Use table tools for table/column/relation schema changes. enfyra_column and enfyra_session are internal/no-route; do not CRUD them directly.',
60
- },
61
- {
62
- area: 'Dynamic REST API',
63
- tables: ['enfyra_route', 'enfyra_route_handler', 'enfyra_pre_hook', 'enfyra_post_hook', 'enfyra_route_permission', 'enfyra_method'],
64
- workflow: 'Create custom paths with create_route without mainTableId, then add handlers/hooks. mainTableId is only for canonical table routes like /table_name. Query enfyra_method before assigning route methods.',
65
- },
66
- {
67
- area: 'Auth, roles, sessions, OAuth',
68
- tables: ['enfyra_user', 'enfyra_role', 'enfyra_api_token', 'enfyra_session', 'enfyra_oauth_config', 'enfyra_oauth_account'],
69
- workflow: 'MCP auth exchanges ENFYRA_API_TOKEN through /auth/token/exchange. Configure an API token from Enfyra admin UI /me.',
70
- },
71
- {
72
- area: 'Guards and permissions',
73
- tables: ['enfyra_guard', 'enfyra_guard_rule', 'enfyra_field_permission', 'enfyra_column_rule'],
74
- workflow: 'Use route guard metadata for request gating, field permissions for record field access, and column rules for body validation.',
75
- },
76
- {
77
- area: 'GraphQL',
78
- tables: ['enfyra_graphql'],
79
- workflow: 'Enable per table through enfyra_graphql or update_table graphqlEnabled. GraphQL table data requires Bearer auth; anonymous root or schema probes may return 200 without exposing table data.',
80
- },
81
- {
82
- area: 'Files and storage',
83
- tables: ['enfyra_file', 'enfyra_file_permission', 'enfyra_folder', 'enfyra_storage_config'],
84
- workflow: 'Use file endpoints/helpers for uploads and asset streaming; metadata tables describe files, permissions, folders, and storage backends.',
85
- },
86
- {
87
- area: 'WebSocket',
88
- tables: ['enfyra_websocket', 'enfyra_websocket_event'],
89
- workflow: 'Socket.IO gateways/events are metadata-backed. Use admin test runner for handler scripts before relying on a real client.',
90
- },
91
- {
92
- area: 'Flows',
93
- tables: ['enfyra_flow', 'enfyra_flow_step', 'enfyra_flow_execution'],
94
- workflow: 'Create flows as small operation-sized steps via CRUD, test steps with test_flow_step/run_admin_test, trigger with trigger_flow. Split oversized scripts instead of adding more work to one step.',
95
- },
96
- {
97
- area: 'Extensions, menus, packages',
98
- tables: ['enfyra_extension', 'enfyra_menu', 'enfyra_package', 'enfyra_bootstrap_script'],
99
- workflow: 'Extensions are Vue SFC records. Use install_package for enfyra_package rather than raw CRUD.',
100
- },
101
- {
102
- area: 'Settings and platform config',
103
- tables: ['enfyra_setting', 'enfyra_cors_origin'],
104
- workflow: 'Settings and CORS origins are metadata-backed platform configuration.',
105
- },
106
- ];
107
-
108
- const FILTER_OPERATORS = [
109
- '_eq',
110
- '_neq',
111
- '_gt',
112
- '_gte',
113
- '_lt',
114
- '_lte',
115
- '_in',
116
- '_not_in',
117
- '_nin',
118
- '_contains',
119
- '_starts_with',
120
- '_ends_with',
121
- '_between',
122
- '_is_null',
123
- '_is_not_null',
124
- '_and',
125
- '_or',
126
- '_not',
127
- ];
128
-
129
- const DEFAULT_ME_PERMISSION_FIELDS = [
130
- 'id',
131
- 'email',
132
- 'isRootAdmin',
133
- 'role.id',
134
- 'role.name',
135
- 'role.routePermissions.id',
136
- 'role.routePermissions.isEnabled',
137
- 'role.routePermissions.methods.id',
138
- 'role.routePermissions.methods.name',
139
- 'role.routePermissions.route.id',
140
- 'role.routePermissions.route.path',
141
- 'role.routePermissions.allowedUsers.id',
142
- 'allowedRoutePermissions.id',
143
- 'allowedRoutePermissions.isEnabled',
144
- 'allowedRoutePermissions.methods.id',
145
- 'allowedRoutePermissions.methods.name',
146
- 'allowedRoutePermissions.route.id',
147
- 'allowedRoutePermissions.route.path',
148
- 'allowedRoutePermissions.allowedUsers.id',
149
- ];
150
-
151
- const MCP_PERMISSION_REQUIREMENTS = [
152
- {
153
- area: 'script validation',
154
- tools: ['validate_dynamic_script', 'create_handler', 'create_pre_hook', 'create_post_hook', 'patch_script_source', 'update_script_source', 'ensure_script_flow_step', 'ensure_condition_flow_step', 'ensure_websocket_event'],
155
- route: '/admin/script/validate',
156
- methods: ['POST'],
157
- },
158
- {
159
- area: 'flow and websocket test runner',
160
- tools: ['run_admin_test', 'test_flow_step'],
161
- route: '/admin/test/run',
162
- methods: ['POST'],
163
- },
164
- {
165
- area: 'manual flow trigger',
166
- tools: ['trigger_flow'],
167
- route: '/admin/flow/trigger/:id',
168
- methods: ['POST'],
169
- },
170
- {
171
- area: 'route cache reload',
172
- tools: ['reload_routes', 'enable_route', 'disable_route', 'delete_route', 'public_route_methods', 'private_route_methods', 'add_route_methods', 'replace_route_methods', 'remove_route_methods', 'ensure_route_access'],
173
- route: '/admin/reload/routes',
174
- methods: ['POST'],
175
- },
176
- {
177
- area: 'menu reorder',
178
- tools: ['reorder_menus'],
179
- route: '/admin/menu/reorder',
180
- methods: ['POST'],
181
- },
182
- {
183
- area: 'metadata cache reload',
184
- tools: ['reload_metadata'],
185
- route: '/admin/reload/metadata',
186
- methods: ['POST'],
187
- },
188
- {
189
- area: 'GraphQL cache reload',
190
- tools: ['reload_graphql', 'set_table_graphql'],
191
- route: '/admin/reload/graphql',
192
- methods: ['POST'],
193
- },
194
- {
195
- area: 'full cache reload',
196
- tools: ['reload_all'],
197
- route: '/admin/reload',
198
- methods: ['POST'],
199
- },
200
- ];
201
-
202
- const FIELD_PERMISSION_CONDITION_OPERATORS = [
203
- '_eq',
204
- '_neq',
205
- '_gt',
206
- '_gte',
207
- '_lt',
208
- '_lte',
209
- '_in',
210
- '_not_in',
211
- '_nin',
212
- '_is_null',
213
- '_is_not_null',
214
- '_and',
215
- '_or',
216
- '_not',
217
- ];
218
-
219
- const SCRIPT_BACKED_TABLES = [
220
- 'enfyra_route_handler',
221
- 'enfyra_pre_hook',
222
- 'enfyra_post_hook',
223
- 'enfyra_flow_step',
224
- 'enfyra_websocket_event',
225
- 'enfyra_websocket',
226
- 'enfyra_graphql',
227
- 'enfyra_bootstrap_script',
228
- ];
229
- const SCRIPT_BACKED_TABLE_SET = new Set(SCRIPT_BACKED_TABLES);
230
-
231
- const SCRIPT_SOURCE_FIELDS = [
232
- 'sourceCode',
233
- 'handlerScript',
234
- 'connectionHandlerScript',
235
- 'code',
236
- ];
237
-
238
- function normalizeTables(metadata) {
239
- const tablesSource = metadata?.data?.tables || metadata?.tables || metadata?.data || [];
240
- return Array.isArray(tablesSource)
241
- ? tablesSource
242
- : Object.values(tablesSource || {});
243
- }
244
-
245
- function getPrimaryColumn(table) {
246
- return (table?.columns || []).find((column) => column.isPrimary) || null;
247
- }
248
-
249
- function inferPrimaryKeyContext(tables) {
250
- const primaryColumns = tables
251
- .map((table) => ({ table: table.name, primaryKey: getPrimaryColumn(table)?.name || null }))
252
- .filter((item) => item.primaryKey);
253
- const counts = primaryColumns.reduce((acc, item) => {
254
- acc[item.primaryKey] = (acc[item.primaryKey] || 0) + 1;
255
- return acc;
256
- }, {});
257
- const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
258
- return {
259
- dominantPrimaryKey: dominant,
260
- counts,
261
- inferredBackendFamily: dominant === '_id' ? 'mongodb-like' : dominant === 'id' ? 'sql-like' : 'unknown',
262
- exactDatabaseType: 'not exposed by current public/admin API; infer from metadata or add a backend context endpoint for exact mysql/postgres/mongodb',
263
- sampleTables: primaryColumns.slice(0, 12),
264
- };
265
- }
266
-
267
- function getMetadataDatabaseContext(metadata, tables) {
268
- const inferred = inferPrimaryKeyContext(tables);
269
- return {
270
- dbType: metadata?.dbType || metadata?.data?.dbType || null,
271
- pkField: metadata?.pkField || metadata?.data?.pkField || inferred.dominantPrimaryKey,
272
- inferredBackendFamily: inferred.inferredBackendFamily,
273
- primaryKeyCounts: inferred.counts,
274
- source: metadata?.dbType || metadata?.data?.dbType
275
- ? 'metadata'
276
- : 'inferred from table primary columns',
277
- sampleTables: inferred.sampleTables,
278
- };
279
- }
280
-
281
- function summarizeTable(table) {
282
- if (!table) return null;
283
- const relationFkColumnNames = new Set((table.relations || []).flatMap((relation) => {
284
- const propertyName = relation.propertyName;
285
- return propertyName
286
- ? [
287
- `${propertyName}Id`,
288
- `${propertyName}_id`,
289
- relation.fkCol,
290
- relation.fkColumn,
291
- relation.foreignKeyColumn,
292
- ].filter(Boolean).map((name) => String(name).toLowerCase())
293
- : [];
294
- }));
295
- const modelFacingColumns = (table.columns || []).filter((column) => (
296
- column.isPrimary || !relationFkColumnNames.has(String(column.name || '').toLowerCase())
297
- ));
298
- return {
299
- id: table.id ?? table._id,
300
- name: table.name,
301
- alias: table.alias,
302
- primaryKey: getPrimaryColumn(table)?.name || null,
303
- validateBody: table.validateBody,
304
- graphqlEnabled: table.graphqlEnabled,
305
- columns: modelFacingColumns.map((column) => ({
306
- id: column.id ?? column._id,
307
- name: column.name,
308
- type: column.type,
309
- isPrimary: !!column.isPrimary,
310
- isNullable: column.isNullable,
311
- isPublished: column.isPublished,
312
- isUpdatable: column.isUpdatable !== false,
313
- isEncrypted: column.isEncrypted === true,
314
- })),
315
- hiddenRelationColumnCount: (table.columns || []).length - modelFacingColumns.length,
316
- relations: (table.relations || []).map((relation) => ({
317
- id: relation.id ?? relation._id,
318
- propertyName: relation.propertyName,
319
- type: relation.type,
320
- targetTable: relation.targetTable?.name || relation.targetTableName || relation.targetTable,
321
- inversePropertyName: relation.inversePropertyName,
322
- mappedBy: relation.mappedBy?.propertyName || relation.mappedBy,
323
- isNullable: relation.isNullable,
324
- onDelete: relation.onDelete,
325
- isPublished: relation.isPublished,
326
- })),
327
- };
328
- }
329
-
330
- function summarizeRoutes(routesResult) {
331
- return (routesResult?.data || []).map((route) => ({
332
- id: route.id ?? route._id,
333
- path: route.path,
334
- mainTable: route.mainTable?.name || route.mainTableName || null,
335
- availableMethods: (route.availableMethods || []).map((method) => method.name).filter(Boolean),
336
- publicMethods: (route.publicMethods || []).map((method) => method.name).filter(Boolean),
337
- isEnabled: route.isEnabled,
338
- }));
339
- }
340
-
341
- function summarizeMetadata(metadata, { search, limit, all = false } = {}) {
342
- const tables = normalizeTables(metadata);
343
- const q = search ? search.toLowerCase() : null;
344
- const summarized = tables.map((table) => ({
345
- id: table.id ?? table._id,
346
- name: table.name,
347
- alias: table.alias,
348
- primaryKey: getPrimaryColumn(table)?.name || null,
349
- columnCount: (table.columns || []).length,
350
- relationCount: (table.relations || []).length,
351
- routeHint: `Use get_table_metadata({ tableName: "${table.name}" }) for fields and relations.`,
352
- }));
353
- const matched = q
354
- ? summarized.filter((table) => JSON.stringify(table).toLowerCase().includes(q))
355
- : summarized;
356
- const outputLimit = all ? matched.length : (limit || 30);
357
- return {
358
- tableCount: tables.length,
359
- matchedTableCount: matched.length,
360
- returnedTableCount: Math.min(matched.length, outputLimit),
361
- complete: all || outputLimit >= matched.length,
362
- hardCap: all ? null : outputLimit,
363
- search: search || null,
364
- tables: matched.slice(0, outputLimit),
365
- };
366
- }
367
-
368
- function unwrapData(result) {
369
- return Array.isArray(result?.data) ? result.data : [];
370
- }
371
-
372
- function getId(record) {
373
- return record?.id ?? record?._id ?? null;
374
- }
375
-
376
- function sameId(a, b) {
377
- if (a === null || a === undefined || b === null || b === undefined) return false;
378
- return String(a) === String(b);
379
- }
380
-
381
- function refId(value) {
382
- return typeof value === 'object' && value !== null ? getId(value) : value;
383
- }
384
-
385
- function firstDataRecord(result) {
386
- return Array.isArray(result?.data) ? result.data[0] : result;
387
- }
388
-
389
- function resultRecordId(result) {
390
- return getId(firstDataRecord(result));
391
- }
392
-
393
- function normalizePermissionRoute(routePath) {
394
- const value = String(routePath || '').trim();
395
- return value.startsWith('/') ? value : `/${value}`;
396
- }
397
-
398
- function methodNames(permission) {
399
- return normalizeMethodNames((permission?.methods || []).map((method) => method?.name || method));
400
- }
401
-
402
- function permissionAllowedUserIds(permission) {
403
- return (permission?.allowedUsers || []).map((user) => String(refId(user))).filter(Boolean);
404
- }
405
-
406
- function permissionMatchesUser(permission, userId) {
407
- const allowed = permissionAllowedUserIds(permission);
408
- if (!allowed.length) return true;
409
- return userId ? allowed.includes(String(userId)) : false;
410
- }
411
-
412
- function directPermissionMatchesUser(permission, userId) {
413
- const allowed = permissionAllowedUserIds(permission);
414
- return userId ? allowed.includes(String(userId)) : false;
415
- }
416
-
417
- function userHasRoutePermission(user, routePath, method) {
418
- if (!user) return false;
419
- if (user.isRootAdmin) return true;
420
-
421
- const normalizedRoute = normalizePermissionRoute(routePath);
422
- const normalizedMethod = String(method || '').toUpperCase();
423
- const userId = getId(user);
424
- const directPermissions = user.allowedRoutePermissions || [];
425
- const rolePermissions = user.role?.routePermissions || [];
426
-
427
- const matchesRouteAndMethod = (permission) => (
428
- permission?.isEnabled !== false
429
- && permission?.route?.path === normalizedRoute
430
- && methodNames(permission).includes(normalizedMethod)
431
- );
432
-
433
- return directPermissions.some((permission) => (
434
- matchesRouteAndMethod(permission)
435
- && directPermissionMatchesUser(permission, userId)
436
- )) || rolePermissions.some((permission) => (
437
- matchesRouteAndMethod(permission)
438
- && permissionMatchesUser(permission, userId)
439
- ));
440
- }
441
-
442
- function summarizePermissionProfile(user) {
443
- const requirements = MCP_PERMISSION_REQUIREMENTS.map((requirement) => {
444
- const methods = requirement.methods.map((method) => ({
445
- method,
446
- allowed: userHasRoutePermission(user, requirement.route, method),
447
- }));
448
- return {
449
- ...requirement,
450
- methods,
451
- allowed: methods.every((item) => item.allowed),
452
- };
453
- });
454
-
455
- return {
456
- user: user ? {
457
- id: getId(user),
458
- email: user.email || null,
459
- isRootAdmin: !!user.isRootAdmin,
460
- role: user.role ? {
461
- id: getId(user.role),
462
- name: user.role.name || null,
463
- } : null,
464
- } : null,
465
- permissionModel: {
466
- sameAsAdminUi: 'Mirrors Enfyra admin usePermissions(): root admin passes; otherwise direct allowedRoutePermissions are checked before role.routePermissions.',
467
- publicMethods: 'Anonymous REST access is controlled by route.publicMethods; this profile only reports authenticated route permissions for the configured token.',
468
- },
469
- mcpRequirements: requirements,
470
- missingRequirements: requirements
471
- .filter((item) => !item.allowed)
472
- .map((item) => ({
473
- area: item.area,
474
- route: item.route,
475
- methods: item.methods.filter((method) => !method.allowed).map((method) => method.method),
476
- tools: item.tools,
477
- })),
478
- };
479
- }
480
-
481
- function parseJsonArg(value, fallback = undefined) {
482
- if (value === undefined || value === null || value === '') return fallback;
483
- return JSON.parse(value);
484
- }
485
-
486
- async function reloadRoutesResult() {
487
- try {
488
- const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' });
489
- return {
490
- attempted: true,
491
- succeeded: true,
492
- result,
493
- };
494
- } catch (error) {
495
- return {
496
- attempted: true,
497
- succeeded: false,
498
- error: error?.message || String(error),
499
- };
500
- }
501
- }
502
-
503
- function normalizeRestPath(path) {
504
- if (!path) return '/';
505
- if (/^https?:\/\//i.test(path)) {
506
- throw new Error('Only Enfyra API paths are allowed, not full external URLs');
507
- }
508
- return path.startsWith('/') ? path : `/${path}`;
509
- }
510
-
511
- function pickCodeSummary(record, fieldName) {
512
- const code = record?.[fieldName];
513
- return {
514
- ...record,
515
- [fieldName]: typeof code === 'string'
516
- ? {
517
- length: code.length,
518
- preview: code.length > 700 ? `${code.slice(0, 700)}...` : code,
519
- }
520
- : code,
521
- };
522
- }
523
-
524
- function summarizeMutationResult(result, action, tableName) {
525
- const record = firstDataRecord(result);
526
- return {
527
- action,
528
- tableName,
529
- id: getId(record),
530
- statusCode: result?.statusCode,
531
- success: result?.success,
532
- detailHint: `Use find_one_record or query_table with explicit fields to inspect ${tableName}.`,
533
- };
534
- }
535
-
536
- async function getTableSummary(tableName) {
537
- const result = await fetchAPI(ENFYRA_API_URL, `/metadata/${tableName}`);
538
- const table = result?.data?.table || result?.data || result?.table || result;
539
- return summarizeTable(table);
540
- }
541
-
542
- async function getPrimaryFieldName(tableName) {
543
- const table = await getTableSummary(tableName);
544
- return table?.primaryKey || 'id';
545
- }
546
-
547
- async function fetchAll(path) {
548
- return unwrapData(await fetchAPI(ENFYRA_API_URL, path));
549
- }
550
-
551
- function targetInstance() {
552
- return {
553
- apiBase: ENFYRA_API_URL.replace(/\/$/, ''),
554
- source: 'ENFYRA_API_URL environment variable used by this MCP server process',
555
- };
556
- }
557
-
558
- async function discoveryFetch(path, { fallbackData = [], timeoutMs = DISCOVERY_FETCH_TIMEOUT_MS } = {}) {
559
- let timeoutId;
560
- try {
561
- const timeout = new Promise((_, reject) => {
562
- timeoutId = setTimeout(() => {
563
- reject(new Error(`Discovery request timeout after ${timeoutMs}ms for ${path}`));
564
- }, timeoutMs);
565
- });
566
- return await Promise.race([
567
- fetchAPI(ENFYRA_API_URL, path),
568
- timeout,
569
- ]);
570
- } catch (error) {
571
- return {
572
- statusCode: null,
573
- success: false,
574
- error: String(error?.message || error),
575
- data: fallbackData,
576
- };
577
- } finally {
578
- if (timeoutId) clearTimeout(timeoutId);
579
- }
580
- }
581
-
582
- function collectPartialErrors(results) {
583
- return Object.entries(results)
584
- .filter(([, result]) => result?.error)
585
- .map(([name, result]) => ({ name, error: result.error }));
586
- }
587
-
588
- async function getMetadataTables() {
589
- const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
590
- return {
591
- metadata,
592
- tables: normalizeTables(metadata),
593
- };
594
- }
595
-
596
- function resolveTableOrThrow(tables, tableName) {
597
- const table = tables.find((item) => item?.name === tableName || item?.alias === tableName);
598
- if (!table) throw new Error(`Unknown table "${tableName}"`);
599
- return table;
600
- }
601
-
602
- function resolveFieldOrThrow(table, fieldName, kind = 'column') {
603
- const list = kind === 'relation' ? table.relations || [] : table.columns || [];
604
- const field = list.find((item) => item.name === fieldName || item.propertyName === fieldName);
605
- if (!field) throw new Error(`Unknown ${kind} "${fieldName}" on table "${table.name}"`);
606
- return field;
607
- }
608
-
609
- async function prepareGenericMutation(tableName, data) {
610
- const { tables } = await getMetadataTables();
611
- return prepareRecordMutation({
612
- fetchAPI,
613
- apiUrl: ENFYRA_API_URL,
614
- tables,
615
- tableName,
616
- data,
617
- });
618
- }
619
-
620
- function assertKnowledgeForGenericMutation(tableName, data, { knowledgeAckKey, extensionKnowledgeAckKey }) {
621
- const payload = parseRecordData(data);
622
- assertDynamicCodeKnowledgeAckIf(SCRIPT_BACKED_TABLE_SET.has(tableName) && typeof payload.sourceCode === 'string', knowledgeAckKey);
623
- assertExtensionKnowledgeAckIf(tableName === 'enfyra_extension' && typeof payload.code === 'string', extensionKnowledgeAckKey);
624
- }
625
-
626
- async function validateExtensionCodeForGenericMutation(tableName, payload, fallbackName) {
627
- if (tableName !== 'enfyra_extension' || typeof payload?.code !== 'string') return null;
628
- return validateExtensionCode(ENFYRA_API_URL, payload.code, payload.name || fallbackName);
629
- }
630
-
631
- function parseQueryParamsArg(queryParams) {
632
- const parsed = parseJsonArg(queryParams, {});
633
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
634
- throw new Error('queryParams must be a JSON object string.');
635
- }
636
- const params = new URLSearchParams();
637
- for (const [key, value] of Object.entries(parsed)) {
638
- if (value === undefined || value === null) continue;
639
- params.set(key, String(value));
640
- }
641
- return params.toString();
642
- }
643
-
644
- function appendQuery(path, queryParams) {
645
- if (!queryParams) return path;
646
- return `${path}${path.includes('?') ? '&' : '?'}${queryParams}`;
647
- }
648
-
649
- const METHOD_NAME_RE = /^[A-Z][A-Z0-9_]*$/;
650
- const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
651
-
652
- function normalizeMethodNameInput(method) {
653
- const value = String(method || '').trim().toUpperCase();
654
- if (!METHOD_NAME_RE.test(value)) {
655
- throw new Error('Method must start with A-Z and contain only uppercase letters, numbers, or underscore.');
656
- }
657
- return value;
658
- }
659
-
660
- function normalizeHexColorInput(value, fieldName) {
661
- const color = String(value || '').trim().toLowerCase();
662
- if (!HEX_COLOR_RE.test(color)) {
663
- throw new Error(`${fieldName} must be a full hex color such as #1d4ed8.`);
664
- }
665
- return color;
666
- }
667
-
668
- function sha256(value) {
669
- return createHash('sha256').update(String(value), 'utf8').digest('hex');
670
- }
671
-
672
- function getScriptSourceField(record) {
673
- for (const field of SCRIPT_SOURCE_FIELDS) {
674
- if (typeof record?.[field] === 'string') return field;
675
- }
676
- if (record?.config && typeof record.config === 'object' && typeof record.config.code === 'string') {
677
- return 'config.code';
678
- }
679
- return null;
680
- }
681
-
682
- function getRecordSource(record) {
683
- const field = getScriptSourceField(record);
684
- if (!field) return { field: null, sourceCode: '' };
685
- if (field === 'config.code') return { field, sourceCode: record.config.code };
686
- return { field, sourceCode: record[field] };
687
- }
688
-
689
- async function fetchRecordByPrimaryKey(tableName, id, fields = '*') {
690
- const primaryKey = await getPrimaryFieldName(tableName);
691
- const query = new URLSearchParams({
692
- filter: JSON.stringify({ [primaryKey]: { _eq: id } }),
693
- limit: '1',
694
- fields,
695
- });
696
- const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}?${query.toString()}`);
697
- const record = unwrapData(result)[0] || null;
698
- if (!record) throw new Error(`${tableName} record ${id} was not found.`);
699
- return { primaryKey, record };
700
- }
701
-
702
- async function fetchScriptRecord(tableName, id) {
703
- validateTableName(tableName);
704
- if (!SCRIPT_BACKED_TABLES.includes(tableName)) {
705
- throw new Error(`Unsupported script-backed table "${tableName}". Supported: ${SCRIPT_BACKED_TABLES.join(', ')}`);
706
- }
707
- const { primaryKey, record } = await fetchRecordByPrimaryKey(tableName, id, '*');
708
- const { field, sourceCode } = getRecordSource(record);
709
- if (!field) {
710
- throw new Error(`${tableName} record ${id} does not expose a known editable source field.`);
711
- }
712
- return { primaryKey, record, sourceField: field, sourceCode };
713
- }
714
-
715
- function countOccurrences(source, needle) {
716
- if (!needle) return 0;
717
- let count = 0;
718
- let index = 0;
719
- while (true) {
720
- index = source.indexOf(needle, index);
721
- if (index === -1) return count;
722
- count += 1;
723
- index += needle.length;
724
- }
725
- }
726
-
727
- function replaceOccurrence(source, oldText, newText, mode) {
728
- const occurrences = countOccurrences(source, oldText);
729
- if (occurrences === 0) {
730
- throw new Error('oldText was not found in the current source.');
731
- }
732
- if (mode === 'first') {
733
- return {
734
- occurrences,
735
- patched: source.replace(oldText, newText),
736
- replaced: 1,
737
- };
738
- }
739
- return {
740
- occurrences,
741
- patched: source.split(oldText).join(newText),
742
- replaced: occurrences,
743
- };
744
- }
745
-
746
- function sourcePreview(source, aroundText) {
747
- if (!aroundText) return source.slice(0, 1200);
748
- const index = source.indexOf(aroundText);
749
- if (index === -1) return source.slice(0, 1200);
750
- const start = Math.max(0, index - 500);
751
- const end = Math.min(source.length, index + aroundText.length + 500);
752
- return `${start > 0 ? '...' : ''}${source.slice(start, end)}${end < source.length ? '...' : ''}`;
753
- }
754
-
755
- function scriptRecordLabel(tableName, record) {
756
- const method = record.method?.name || null;
757
- const route = record.route?.path || null;
758
- const flow = record.flow?.name || null;
759
- const gateway = record.gateway?.path || null;
760
- const gqlTable = record.table?.name || null;
761
- return {
762
- tableName,
763
- id: getId(record),
764
- key: record.key || record.name || record.eventName || null,
765
- route,
766
- method,
767
- flow,
768
- gateway,
769
- gqlTable,
770
- };
771
- }
772
-
773
- function scriptTraceFields(tableName) {
774
- const common = 'id,_id,name,key,eventName,sourceCode,handlerScript,connectionHandlerScript,code,scriptLanguage';
775
- const byTable = {
776
- enfyra_route_handler: `${common},route.id,route.path,method.id,method.name`,
777
- enfyra_pre_hook: `${common},route.id,route.path,methods.id,methods.name,isGlobal`,
778
- enfyra_post_hook: `${common},route.id,route.path,methods.id,methods.name,isGlobal`,
779
- enfyra_flow_step: `${common},flow.id,flow.name`,
780
- enfyra_websocket_event: `${common},gateway.id,gateway.path`,
781
- enfyra_websocket: `${common},path`,
782
- enfyra_graphql: `${common},table.id,table.name`,
783
- enfyra_bootstrap_script: common,
784
- };
785
- return byTable[tableName] || '*';
786
- }
787
-
788
- async function findMethodRecordByName(method) {
789
- const filter = encodeURIComponent(JSON.stringify({ name: { _eq: method } }));
790
- const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_method?filter=${filter}&limit=1&fields=id,_id,name,buttonColor,textColor,isSystem`);
791
- return unwrapData(result)[0] || null;
792
- }
793
-
794
- // Create MCP server — `instructions` is sent to the host (e.g. Claude Code) for the LLM; not README
795
- const server = new McpServer(
796
- {
797
- name: 'enfyra-mcp',
798
- version: '1.0.0',
799
- },
800
- {
801
- instructions: buildMcpServerInstructions(ENFYRA_API_URL),
802
- },
803
- );
804
- installColumnarToolFormatter(server);
805
-
806
- // ============================================================================
807
- // METADATA TOOLS
808
- // ============================================================================
809
-
810
- server.tool(
811
- 'get_enfyra_required_knowledge',
812
- [
813
- 'Return required Enfyra knowledge and acknowledgement keys for MCP code-writing tools.',
814
- 'Call this before creating or updating dynamic server code or Enfyra extension code. Read the returned contracts and pass the matching ack key into write tools.',
815
- ].join(' '),
816
- {},
817
- async () => jsonContent(buildRequiredKnowledgePayload()),
818
- );
819
-
820
- server.tool('get_all_metadata', 'Get concise metadata summary for all tables. Use get_table_metadata or inspect_table for detail.', {
821
- includeFull: z.boolean().optional().default(false).describe('Return full raw metadata. Default false to keep MCP context small.'),
822
- search: z.string().optional().describe('Optional table-name/alias substring filter.'),
823
- limit: z.number().optional().describe('Maximum tables returned after search. Default 30.'),
824
- all: z.boolean().optional().default(false).describe('Return every matched table summary. Use when a complete table list is required.'),
825
- }, async ({ includeFull, search, limit, all }) => {
826
- if (all && limit !== undefined) {
827
- throw new Error('get_all_metadata accepts either all=true or limit, not both.');
828
- }
829
- const result = await fetchAPI(ENFYRA_API_URL, '/metadata');
830
- const payload = includeFull
831
- ? result
832
- : {
833
- statusCode: result?.statusCode,
834
- success: result?.success,
835
- ...summarizeMetadata(result, { search, limit, all }),
836
- detailHint: all
837
- ? 'Complete summary returned. Call get_table_metadata({ tableName }) or inspect_table({ tableName }) for columns, relations, and route context.'
838
- : 'Default response is capped and minimal. Pass all=true for a complete summary, or call get_table_metadata({ tableName }) / inspect_table({ tableName }) for detail.',
839
- };
840
- return jsonContent(payload);
841
- });
842
-
843
- server.tool('get_table_metadata', 'Get concise metadata for a specific table by name', {
844
- tableName: z.string().describe('Table name (e.g., "enfyra_user", "enfyra_route")'),
845
- includeFull: z.boolean().optional().default(false).describe('Return full raw table metadata. Default false to keep MCP context small.'),
846
- }, async ({ tableName, includeFull }) => {
847
- const result = await fetchAPI(ENFYRA_API_URL, `/metadata/${tableName}`);
848
- const table = result?.data?.table || result?.data || result?.table || result;
849
- const payload = includeFull
850
- ? result
851
- : {
852
- statusCode: result?.statusCode,
853
- success: result?.success,
854
- table: summarizeTable(table),
855
- queryHint: `Use query_table({ tableName: "${tableName}", fields: [...] }) for records. query_table without fields returns only the primary key.`,
856
- };
857
- return jsonContent(payload);
858
- });
859
-
860
- server.tool(
861
- 'get_enfyra_examples',
862
- [
863
- 'Return concrete Enfyra examples by category.',
864
- 'Use this before generating schemas, queries, handlers/hooks, SSR app auth, OAuth, Socket.IO, flows, files, or extensions so implementation details follow proven patterns.',
865
- ].join(' '),
866
- {
867
- category: z.enum(listExampleCategories().map((item) => item.key)).optional().describe('Example category key. Omit to list categories.'),
868
- },
869
- async ({ category }) => {
870
- const result = getExamples(category);
871
- return jsonContent(result);
872
- },
873
- );
874
-
875
- server.tool(
876
- 'discover_enfyra_workflows',
877
- [
878
- 'Progressive-disclosure router for the Enfyra MCP tool surface.',
879
- 'Call this when the task intent is clear but the right Enfyra tool path is not.',
880
- 'Returns matched workflows, first tools, required acknowledgement keys, verification tools, and avoidTools negative-routing boundaries.',
881
- ].join(' '),
882
- {
883
- intent: z.string().optional().describe('Plain-language task goal, e.g. "add a menu chip when support tickets arrive".'),
884
- surface: z.enum(WORKFLOW_SURFACES).optional().describe('Known surface when the caller can classify the task. Omit to infer from intent.'),
885
- risk: z.enum(['read', 'write', 'destructive', 'debug', 'unknown']).optional().default('unknown').describe('Highest expected operation risk.'),
886
- detail: z.enum(['summary', 'plan', 'full']).optional().default('summary').describe('summary lists candidate workflows; plan adds tool sequence and avoidTools; full also includes matching keywords.'),
887
- limit: z.number().int().positive().max(10).optional().default(5).describe('Maximum workflows to return.'),
888
- },
889
- async (input) => jsonContent(discoverWorkflowRoutes(input)),
890
- );
891
-
892
- server.tool(
893
- 'discover_enfyra_system',
894
- [
895
- 'Call this first when you need to understand the live Enfyra instance.',
896
- 'Returns a concise capability map from live metadata/routes/method rows, including schema management, REST route behavior, GraphQL enablement, and relation handling.',
897
- 'Do not use this only to confirm the API base; use get_enfyra_api_context for that cheaper target check.',
898
- 'Run broad discovery tools sequentially; do not call multiple broad discovery tools in parallel.',
899
- ].join(' '),
900
- {},
901
- async () => {
902
- const metadata = await discoveryFetch('/metadata');
903
- const routesResult = await discoveryFetch('/enfyra_route?fields=path,mainTable.name,availableMethods.*,publicMethods.*&limit=1000');
904
- const methodsResult = await discoveryFetch('/enfyra_method?limit=100');
905
-
906
- const tables = normalizeTables(metadata);
907
- const tableNames = tables.map((table) => table?.name).filter(Boolean).sort();
908
- const routes = summarizeRoutes(routesResult);
909
- const routeTables = new Set(routes.map((route) => route.mainTable).filter(Boolean));
910
- const noRouteTables = tableNames.filter((name) => !routeTables.has(name));
911
- const relationTable = tables.find((table) => table?.name === 'enfyra_relation');
912
- const tableDefinition = tables.find((table) => table?.name === 'enfyra_table');
913
- const gqlDefinition = tables.find((table) => table?.name === 'enfyra_graphql');
914
- const routeTableList = [...routeTables].sort();
915
- const noRouteTableList = noRouteTables.sort();
916
- const sample = (items, max = 40) => ({
917
- total: items.length,
918
- returned: Math.min(items.length, max),
919
- items: items.slice(0, max),
920
- truncated: items.length > max,
921
- });
922
-
923
- const payload = {
924
- targetInstance: targetInstance(),
925
- apiBase: ENFYRA_API_URL.replace(/\/$/, ''),
926
- partialErrors: collectPartialErrors({ metadata, routesResult, methodsResult }),
927
- counts: {
928
- tables: tableNames.length,
929
- routes: routes.length,
930
- methods: methodsResult?.data?.length || 0,
931
- },
932
- methods: (methodsResult?.data || []).map((method) => ({ id: method.id || method._id, name: method.name })),
933
- capabilityAreas: CAPABILITY_AREAS.map((item) => ({
934
- ...item,
935
- presentTables: item.tables.filter((table) => tableNames.includes(table)),
936
- routeBackedTables: item.tables.filter((table) => routeTables.has(table)),
937
- noRouteTables: item.tables.filter((table) => tableNames.includes(table) && !routeTables.has(table)),
938
- })),
939
- rest: {
940
- routePattern: 'Dynamic REST routes expose GET/POST at /<route-path> and PATCH/DELETE at /<route-path>/:id; there is no GET /<route-path>/:id.',
941
- publicAccess: 'publicMethods controls anonymous REST access per route/method; otherwise Bearer JWT + routePermissions apply.',
942
- routeTables: sample(routeTableList),
943
- noRouteTables: sample(noRouteTableList),
944
- canonicalCrudTools: 'query_table/create_record/update_record/delete_record use dynamic REST routes and only work for route-backed tables.',
945
- customRouteWorkflow: 'For a new endpoint use create_route without mainTableId, then create_handler/create_pre_hook/create_post_hook. Do not create a table just to get a path.',
946
- routeSamples: sample(routes, 25),
947
- detailHint: 'Use get_all_routes({ search, limit }) or inspect_route({ path }) for route details. Use inspect_table({ tableName }) for table detail.',
948
- },
949
- schemaManagement: {
950
- createTable: 'POST /enfyra_table supports isSingleRecord at create time and supports columns and relations arrays in the same cascade call. MCP create_table exposes isSingleRecord, columns, and relations directly. It does not accept alias at create time; table name drives the default route/schema behavior.',
951
- updateTable: 'PATCH /enfyra_table/:id is the canonical path for table property changes and column/relation schema changes.',
952
- columns: 'enfyra_column has no REST route; use create_table/create_column/update_column/delete_column.',
953
- relations: routeTables.has('enfyra_relation')
954
- ? 'enfyra_relation has a REST route for reads/metadata, but canonical schema migration is create_relation/delete_relation or enfyra_table PATCH with the full relations array. Relation onDelete accepts CASCADE, SET NULL, or RESTRICT.'
955
- : 'Use create_relation/delete_relation or enfyra_table PATCH with the full relations array. Relation onDelete accepts CASCADE, SET NULL, or RESTRICT.',
956
- relationCascadeFkContract: 'Do not ask for or send physical FK/junction column names in relation create/update payloads. Enfyra derives fk/junction columns from relation propertyName/table metadata and hides FK columns from app schema/forms. Use targetTable, type, propertyName, inversePropertyName or mappedBy, isNullable, onDelete. Add inversePropertyName only when a concrete response, UI, deep query, aggregate sort/count, or parent-to-child traversal will use the reverse field.',
957
- tableDefinitionRelations: (tableDefinition?.relations || []).map((rel) => rel.propertyName),
958
- relationDefinitionRelations: (relationTable?.relations || []).map((rel) => rel.propertyName),
959
- },
960
- adminTesting: {
961
- runAdminTest: 'run_admin_test wraps POST /admin/test/run for flow_step, websocket_event, and websocket_connection scripts.',
962
- testFlowStep: 'test_flow_step also wraps POST /admin/test/run with kind=flow_step.',
963
- triggerFlow: 'trigger_flow wraps POST /admin/flow/trigger/:id and enqueues a flow execution.',
964
- },
965
- graphql: {
966
- endpoint: `${ENFYRA_API_URL.replace(/\/$/, '')}/graphql`,
967
- schemaEndpoint: `${ENFYRA_API_URL.replace(/\/$/, '')}/graphql-schema`,
968
- enablement: 'A table appears in GraphQL when enfyra_graphql has an enabled row for that table. REST route availableMethods does not enable GraphQL.',
969
- auth: 'GraphQL table data requires Authorization: Bearer <accessToken>; REST publicMethods do not make GraphQL table data anonymous. Anonymous root/schema probes may still return 200.',
970
- management: routeTables.has('enfyra_graphql')
971
- ? 'Use update_table graphqlEnabled or create/update records on enfyra_graphql, then reload_graphql if needed.'
972
- : 'Use update_table graphqlEnabled, then reload_graphql if needed.',
973
- gqlDefinitionColumns: (gqlDefinition?.columns || []).map((column) => column.name),
974
- },
975
- tableSamples: sample(tableNames, 40),
976
- };
977
-
978
- return jsonContent(payload);
979
- },
980
- );
981
-
982
- server.tool(
983
- 'discover_runtime_context',
984
- [
985
- 'Discover live runtime context that affects how an LLM should use Enfyra.',
986
- 'Reports inferred primary key/backend family, route/cache/admin surfaces, active metadata-backed runtime areas, and what is not exposed by the backend API. Run broad discovery tools sequentially; do not call multiple broad discovery tools in parallel.',
987
- ].join(' '),
988
- {},
989
- async () => {
990
- const metadata = await discoveryFetch('/metadata');
991
- const routesResult = await discoveryFetch('/enfyra_route?fields=path,mainTable.name,availableMethods.*,publicMethods.*,isEnabled&limit=1000');
992
- const methodsResult = await discoveryFetch('/enfyra_method?limit=100');
993
- const gqlResult = await discoveryFetch('/enfyra_graphql?limit=1000');
994
- const flowsResult = await discoveryFetch('/enfyra_flow?limit=1000');
995
- const websocketResult = await discoveryFetch('/enfyra_websocket?limit=1000');
996
- const storageResult = await discoveryFetch('/enfyra_storage_config?limit=1000');
997
- const settingsResult = await discoveryFetch('/enfyra_setting?limit=1000');
998
- const meResult = await discoveryFetch('/me', { fallbackData: null });
999
-
1000
- const tables = normalizeTables(metadata);
1001
- const routes = summarizeRoutes(routesResult);
1002
- const routeTables = new Set(routes.map((route) => route.mainTable).filter(Boolean));
1003
- const adminRoutes = routes.filter((route) => route.path?.startsWith('/admin'));
1004
- const publicRoutes = routes.filter((route) => route.publicMethods?.length);
1005
- const sample = (items, max = 25) => ({
1006
- total: items.length,
1007
- returned: Math.min(items.length, max),
1008
- items: items.slice(0, max),
1009
- truncated: items.length > max,
1010
- });
1011
-
1012
- const payload = {
1013
- targetInstance: targetInstance(),
1014
- apiBase: ENFYRA_API_URL.replace(/\/$/, ''),
1015
- partialErrors: collectPartialErrors({
1016
- metadata,
1017
- routesResult,
1018
- methodsResult,
1019
- gqlResult,
1020
- flowsResult,
1021
- websocketResult,
1022
- storageResult,
1023
- settingsResult,
1024
- meResult,
1025
- }),
1026
- authenticatedUser: Array.isArray(meResult?.data) ? meResult.data[0] || null : meResult?.data || null,
1027
- database: getMetadataDatabaseContext(metadata, tables),
1028
- counts: {
1029
- tables: tables.length,
1030
- routes: routes.length,
1031
- routeBackedTables: routeTables.size,
1032
- noRouteTables: tables.filter((table) => !routeTables.has(table.name)).length,
1033
- methods: methodsResult?.data?.length || 0,
1034
- graphqlDefinitions: gqlResult?.data?.length || 0,
1035
- enabledGraphqlDefinitions: (gqlResult?.data || []).filter((row) => row.isEnabled !== false).length,
1036
- flows: flowsResult?.data?.length || 0,
1037
- enabledFlows: (flowsResult?.data || []).filter((row) => row.isEnabled !== false).length,
1038
- websocketGateways: websocketResult?.data?.length || 0,
1039
- enabledWebsocketGateways: (websocketResult?.data || []).filter((row) => row.isEnabled !== false).length,
1040
- storageConfigs: storageResult?.data?.length || 0,
1041
- settings: settingsResult?.data?.length || 0,
1042
- },
1043
- methods: (methodsResult?.data || []).map((method) => ({ id: method.id || method._id, name: method.name })),
1044
- routeRuntime: {
1045
- routePattern: 'GET/POST /<route-path>; PATCH/DELETE /<route-path>/:id; no dynamic GET /<route-path>/:id.',
1046
- adminRoutes: sample(adminRoutes.map((route) => route.path).sort()),
1047
- publicRoutes: sample(publicRoutes.map((route) => ({
1048
- path: route.path,
1049
- mainTable: route.mainTable,
1050
- publicMethods: route.publicMethods,
1051
- }))),
1052
- },
1053
- cacheAndCluster: {
1054
- metadataMutationReloads: 'Metadata-backed mutations emit cache invalidation; admin reload endpoints exist for metadata/routes/graphql/guards/all.',
1055
- runtimeCacheContract: 'REDIS_RUNTIME_CACHE=true stores runtime definition snapshots in Redis so instances with the same NODE_NAME read the same runtime cache namespace.',
1056
- userCacheContract: '$cache/@CACHE uses managed user cache under NODE_NAME:user_cache:* with REDIS_USER_CACHE_LIMIT_MB default 30 MB; quota eviction only removes user cache keys, not runtime cache, BullMQ, Socket.IO, telemetry, or lock keys.',
1057
- multiInstanceContract: 'Backend is cluster-aware through cache invalidation, Redis runtime cache, Redis user cache, and BullMQ paths, but this MCP can only observe metadata/API state, not every node health.',
1058
- flowWorkerContract: 'Flow jobs require the backend flow worker to be initialized after HTTP listen and websocket gateway init; trigger_flow only confirms enqueue/result from admin endpoint.',
1059
- },
1060
- runtimeGaps: [
1061
- metadata?.dbType || metadata?.data?.dbType
1062
- ? null
1063
- : 'Exact database type is not exposed by current MCP-visible API.',
1064
- 'Redis/BullMQ/socket adapter health is not exposed by current MCP-visible API.',
1065
- 'MCP can test flow steps and websocket scripts through admin test endpoints, but not prove every production queue/client path without a real end-to-end client.',
1066
- ].filter(Boolean),
1067
- };
1068
- return jsonContent(payload);
1069
- },
1070
- );
1071
-
1072
- server.tool(
1073
- 'discover_query_capabilities',
1074
- [
1075
- 'Discover Enfyra query/filter/deep-fetch capabilities for the live instance.',
1076
- 'Prefer passing tableName. Without tableName this returns only generic query rules. Run broad discovery tools sequentially; do not call multiple broad discovery tools in parallel.',
1077
- ].join(' '),
1078
- {
1079
- tableName: z.string().optional().describe('Optional table name to summarize query fields and relation/deep capabilities.'),
1080
- },
1081
- async ({ tableName }) => {
1082
- const metadata = tableName
1083
- ? await discoveryFetch(`/metadata/${encodeURIComponent(tableName)}`)
1084
- : null;
1085
- const routesResult = tableName
1086
- ? await discoveryFetch('/enfyra_route?fields=path,mainTable.name,availableMethods.*,publicMethods.*,isEnabled&limit=1000')
1087
- : { data: [] };
1088
- const tableFromMetadata = tableName && !metadata?.error
1089
- ? metadata?.data?.table || metadata?.data || metadata?.table || metadata
1090
- : null;
1091
- const tables = tableName
1092
- ? (tableFromMetadata ? [tableFromMetadata] : [])
1093
- : [];
1094
- const routes = summarizeRoutes(routesResult);
1095
- const table = tableName ? tables.find((item) => item.name === tableName) : null;
1096
- const primaryKey = table ? getPrimaryColumn(table)?.name || 'id' : 'id';
1097
- const tableRoutes = tableName
1098
- ? routes.filter((route) => route.mainTable === tableName)
1099
- : [];
1100
-
1101
- const payload = {
1102
- targetInstance: targetInstance(),
1103
- partialErrors: collectPartialErrors({ metadata, routesResult }),
1104
- operators: {
1105
- filter: FILTER_OPERATORS,
1106
- fieldPermissionConditions: FIELD_PERMISSION_CONDITION_OPERATORS,
1107
- fieldPermissionConditionUnsupported: ['_contains', '_starts_with', '_ends_with', '_between'],
1108
- },
1109
- queryParams: {
1110
- fields: 'Comma-separated scalar/relation fields. Relations use relation propertyName, not physical FK column names.',
1111
- filter: 'JSON object using operators above. Relation filters use nested relation propertyName objects.',
1112
- sort: 'Local field or -field. For direct one-to-many/many-to-many parent ordering, use _count(relation), _max(relation.field), or _min(relation.field); raw dotted to-many sort is invalid.',
1113
- page: '1-based page.',
1114
- limit: 'Page size.',
1115
- meta: 'Request metadata/counts where supported.',
1116
- deep: 'Nested relation fetch object keyed by relation propertyName.',
1117
- },
1118
- countPattern: 'For counts, query only fields=id with limit=1 and request meta. Use meta=totalCount without a filter, or meta=filterCount when a filter is supplied. MCP count_records wraps this pattern.',
1119
- security: 'Filters, sorts, counts, and aggregate values can leak information even when a field is not selected. In generated public/user-facing APIs, do not filter, sort, count, or aggregate unpublished fields or private relations unless the endpoint intentionally exposes that fact.',
1120
- deep: {
1121
- shape: '{ [relationName]: { fields?, filter?, sort?, limit?, page?, deep? } }',
1122
- rules: [
1123
- 'Unknown relation keys are invalid.',
1124
- 'Unknown deep entry keys are invalid.',
1125
- 'limit on many-to-one/one-to-one relations is invalid.',
1126
- 'Dotted sort through one-to-many/many-to-many is invalid.',
1127
- 'Deep sort orders rows inside the related collection only; use root aggregate sort helpers when parent rows must be ordered by child values.',
1128
- 'Nested deep is recursively validated.',
1129
- 'Field permissions may rewrite filters/sorts and sanitize post-query results.',
1130
- ],
1131
- },
1132
- backendNotes: {
1133
- primaryKey: tableName
1134
- ? 'Use this table metadata primary column when available.'
1135
- : 'SQL commonly uses id; Mongo uses _id. Use table metadata primary column when available.',
1136
- relationNames: 'API relation operations use relation propertyName, not physical FK column names.',
1137
- relationCascadeFkContract: 'When creating relations through create_table/create_relation/enfyra_table PATCH, never provide fkCol/fkColumn/foreignKeyColumn/sourceColumn/targetColumn/junction*Column. These are physical implementation details derived by Enfyra and hidden from app schema/forms. Add inversePropertyName only for a concrete reverse traversal such as parent deep child lists, response fields, UI sections, or aggregate sort/count.',
1138
- graphql: 'GraphQL query args also accept filter/sort/page/limit. Table data requires Bearer auth and table enablement via enfyra_graphql; anonymous root/schema probes may still return 200.',
1139
- },
1140
- table: tableName
1141
- ? {
1142
- exists: !!table,
1143
- metadata: summarizeTable(table),
1144
- routes: tableRoutes,
1145
- examples: table
1146
- ? {
1147
- list: `GET /${tableRoutes[0]?.path?.replace(/^\//, '') || table.name}?limit=10`,
1148
- oneByPkFilter: { [primaryKey]: { _eq: '<id>' } },
1149
- relationDeep: (table.relations || [])[0]
1150
- ? { [(table.relations || [])[0].propertyName]: { fields: ['id'], limit: 5 } }
1151
- : null,
1152
- relationFilter: (table.relations || [])[0]
1153
- ? { [(table.relations || [])[0].propertyName]: { [primaryKey]: { _eq: '<related-id>' } } }
1154
- : null,
1155
- }
1156
- : null,
1157
- }
1158
- : null,
1159
- discoveryRule: 'When building a query, inspect table metadata first, then use relation propertyName and primary column from that metadata.',
1160
- };
1161
-
1162
- return jsonContent(payload);
1163
- },
1164
- );
1165
-
1166
- server.tool(
1167
- 'discover_script_contexts',
1168
- [
1169
- 'Discover runtime script contexts and macro availability for handlers, hooks, flows, websocket scripts, GraphQL, packages, and extensions.',
1170
- 'Use before writing dynamic JavaScript logic so the model does not mix context variables across surfaces. This tool is static and safe to call alone; avoid running it in parallel with other broad discovery calls.',
1171
- ].join(' '),
1172
- {},
1173
- async () => {
1174
- const payload = {
1175
- targetInstance: targetInstance(),
1176
- transformer: {
1177
- rule: 'Dynamic server scripts are transformed before sandbox execution. Macros expand to $ctx paths; comments are not transformed.',
1178
- preferredSyntax: 'Prefer template macros in generated Enfyra scripts. Use macros such as @BODY/@QUERY/@PARAMS/@USER/@REQ/@RES/@REPOS/@CACHE/@HELPERS/@FETCH/@STORAGE/@UPLOADED_FILE/@SOCKET/@TRIGGER/@DATA/@ERROR/@STATUS/@ENV/@PKGS/@LOGS/@SHARE/@API/@THROW* instead of raw $ctx access whenever a macro exists. Use raw $ctx only for fields without a macro.',
1179
- coreMacros: {
1180
- '@CACHE': '$ctx.$cache',
1181
- '@REPOS': '$ctx.$repos',
1182
- '@HELPERS': '$ctx.$helpers',
1183
- '@STORAGE': '$ctx.$storage',
1184
- '@FETCH': '$ctx.$helpers.$fetch',
1185
- '@LOGS': '$ctx.$logs',
1186
- '@BODY': '$ctx.$body',
1187
- '@ENV': '$ctx.$env',
1188
- '@DATA': '$ctx.$data',
1189
- '@PARAMS': '$ctx.$params',
1190
- '@QUERY': '$ctx.$query',
1191
- '@USER': '$ctx.$user',
1192
- '@REQ': '$ctx.$req',
1193
- '@RES': '$ctx.$res',
1194
- '@SHARE': '$ctx.$share',
1195
- '@API': '$ctx.$api',
1196
- '@UPLOADED_FILE': '$ctx.$uploadedFile',
1197
- '@PKGS': '$ctx.$pkgs',
1198
- '@SOCKET': '$ctx.$socket',
1199
- '@TRIGGER': '$ctx.$trigger',
1200
- '@FLOW': '$ctx.$flow',
1201
- '@FLOW_PAYLOAD': '$ctx.$flow.$payload',
1202
- '@FLOW_LAST': '$ctx.$flow.$last',
1203
- '@FLOW_META': '$ctx.$flow.$meta',
1204
- '@THROW400': "$ctx.$throw['400']",
1205
- '@THROW401': "$ctx.$throw['401']",
1206
- '@THROW403': "$ctx.$throw['403']",
1207
- '@THROW404': "$ctx.$throw['404']",
1208
- '@THROW409': "$ctx.$throw['409']",
1209
- '@THROW422': "$ctx.$throw['422']",
1210
- '@THROW429': "$ctx.$throw['429']",
1211
- '@THROW500': "$ctx.$throw['500']",
1212
- '@THROW503': "$ctx.$throw['503']",
1213
- '@THROW': '$ctx.$throw',
1214
- '@STATUS': '$ctx.$statusCode',
1215
- '@ERROR': '$ctx.$error',
1216
- },
1217
- flowMacros: {
1218
- '@FLOW': '$ctx.$flow',
1219
- '@FLOW_PAYLOAD': '$ctx.$flow.$payload',
1220
- '@FLOW_LAST': '$ctx.$flow.$last',
1221
- '@FLOW_META': '$ctx.$flow.$meta',
1222
- '#table_name': '$ctx.$repos.table_name',
1223
- },
1224
- cache: {
1225
- contract: '@CACHE and $ctx.$cache use managed user cache. Use logical keys only; Enfyra stores Redis-backed user cache under NODE_NAME:user_cache:* and Redis Admin Key Editor uses the same storage path.',
1226
- quota: 'REDIS_USER_CACHE_LIMIT_MB defaults to 30 MB. If exceeded, Enfyra evicts least-recently-used user-cache keys only; system Redis keys are not counted or evicted.',
1227
- keyRule: 'Do not include NODE_NAME, user_cache:, or Redis namespace prefixes in scripts. Prefer TTL-based set(key, value, ttlMs); setNoExpire may still be evicted by the user-cache soft allocation.',
1228
- },
1229
- throws: '@THROW400 through @THROW503 and @THROW map to $ctx.$throw helpers.',
1230
- helpers: {
1231
- core: '$ctx.$helpers includes $bcrypt.hash/compare, autoSlug(text), $fetch, $sleep(ms) capped by the runtime, and $crypto. HTTP and GraphQL contexts also expose $jwt through $ctx.$helpers.',
1232
- fetch: '@FETCH maps to $ctx.$helpers.$fetch for outbound HTTP calls from server scripts. Keep secrets in encrypted fields instead of embedding them in sourceCode.',
1233
- crypto: '$ctx.$helpers.$crypto exposes bounded runtime crypto helpers: randomUUID(), randomBytes(size, encoding), sha256(value, encoding), hmacSha256(value, secret, encoding), and generateSshKeyPair(comment). Use generateSshKeyPair for SSH key material. Do not use legacy $ctx.$helpers.$ssh.',
1234
- files: '$ctx.$storage.$upload and $ctx.$storage.$update accept file: @UPLOADED_FILE for request uploads and stream from the server temp file path. $ctx.$storage.$registerFile creates a enfyra_file record for an object that already exists in storage without uploading bytes. Use buffer only for small generated/transformed files; do not use @UPLOADED_FILE.buffer.',
1235
- },
1236
- env: '$ctx.$env exposes a sanitized process env snapshot with exact sensitive keys removed: DB_URI, DB_REPLICA_URIS, REDIS_URI, SECRET_KEY, and ADMIN_PASSWORD. Store app secrets in unpublished isEncrypted fields instead of reading them from $env.',
1237
- },
1238
- contexts: {
1239
- preHook: {
1240
- runs: 'Before handler.',
1241
- data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REQ', '@REPOS', '@CACHE', '@HELPERS', '@FETCH', '@STORAGE', '@THROW*', '@SOCKET global emit helpers/roomSize'],
1242
- queryContract: '@QUERY.filter is initialized as an object. When adding RLS/scope filters in pre-hooks, merge directly with _and; do not add defensive type checks around @QUERY.filter.',
1243
- projectionContract: 'For canonical table reads, preserve client-controlled query shape. Do not override @QUERY.fields, @QUERY.deep, @QUERY.sort, @QUERY.limit, @QUERY.page, @QUERY.meta, @QUERY.aggregate, or debugMode. RLS should only merge security constraints into @QUERY.filter.',
1244
- rlsPattern: 'For relation-scoped reads, mutate @QUERY.filter instead of returning data. Example: const incomingFilter = @QUERY.filter; const scope = { memberships: { member: { id: { _eq: @USER.id } } } }; @QUERY.filter = Object.keys(incomingFilter).length ? { _and: [incomingFilter, scope] } : scope;',
1245
- returnBehavior: 'Returning a non-undefined value skips handler and becomes response data.',
1246
- },
1247
- handler: {
1248
- runs: 'Main route logic, or canonical CRUD if no handler overrides.',
1249
- data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REQ', '@RES when response streaming is available', '@UPLOADED_FILE for multipart request file metadata', '@REPOS.main secure route main table repo', '@REPOS.secure.<table> secure explicit table repo', '@REPOS.<table> trusted internal table repo', '@CACHE', '@HELPERS', '@FETCH', '@STORAGE', '@PKGS', '@SOCKET global emit helpers/roomSize', '@TRIGGER'],
1250
- queryContract: 'When a handler wraps a canonical table read, pass through client fields/deep/sort/page/limit/meta/aggregate/debugMode unless the route is a clearly custom summary or workflow endpoint.',
1251
- returnBehavior: 'Return value becomes response body unless post-hook changes it.',
1252
- },
1253
- postHook: {
1254
- runs: 'After handler, including error path.',
1255
- data: ['@DATA', '@STATUS', '@ERROR', '@BODY', '@QUERY', '@PARAMS', '@USER', '@REQ', '@CACHE', '@HELPERS', '@FETCH', '@STORAGE', '@SHARE', '@API'],
1256
- returnBehavior: 'Mutate @DATA/$ctx.$data or return a non-undefined replacement response.',
1257
- },
1258
- flowStep: {
1259
- runs: 'Inside flow execution or admin flow step test.',
1260
- data: ['@BODY payload', '@USER if provided', '@FLOW_PAYLOAD', '@FLOW_LAST', '@FLOW', '@FLOW_META', '#table_name', '@CACHE', '@HELPERS', '@FETCH', '@STORAGE', '@SOCKET global emit helpers/roomSize', '@TRIGGER'],
1261
- resultBehavior: 'Step return value is injected into @FLOW.<step.key> and @FLOW_LAST.',
1262
- branching: 'Condition steps use JavaScript truthy/falsy result; child branch is true/false.',
1263
- },
1264
- websocketConnection: {
1265
- runs: 'Socket.IO connection handler.',
1266
- data: ['@BODY connection info', '@DATA connection info', '@REQ websocket request metadata', '@API request metadata', '@USER if authenticated', '@HELPERS', '@FETCH', '@SOCKET reply/join/leave/disconnect/emit helpers/roomSize'],
1267
- },
1268
- websocketEvent: {
1269
- runs: 'Socket.IO event handler.',
1270
- data: ['@BODY event payload', '@DATA event payload', '@REQ websocket request metadata', '@API request metadata', '@USER if authenticated', '@HELPERS', '@FETCH', '@SOCKET reply/join/leave/disconnect/emit helpers/roomSize'],
1271
- resultBehavior: 'Client ack receives queued state first; handler result is emitted asynchronously as ws:result/ws:error with requestId.',
1272
- },
1273
- graphqlResolver: {
1274
- runs: 'Generated GraphQL resolver delegates to dynamic repo/query services.',
1275
- data: ['GraphQL request context', 'Bearer auth user', 'dynamic repositories'],
1276
- caveat: 'REST publicMethods do not make GraphQL table data anonymous.',
1277
- },
1278
- extensionVueSfc: {
1279
- runs: 'Frontend extension code, not server sandbox.',
1280
- data: ['Vue/Nuxt composables', 'Enfyra composables', 'auto-resolved UI components'],
1281
- caveat: 'No import statements; save as enfyra_extension Vue SFC record.',
1282
- },
1283
- },
1284
- helpers: {
1285
- repos: {
1286
- scopes: '$repos.main is the secure repository for the route main table and preserves normal route query behavior. $repos.secure.<table> is the secure repository for explicit table access in public/user-facing custom handlers, hooks, websocket scripts, flows that return data, and third-party app integrations. $repos.<table> is a trusted internal repository for server-owned maintenance/admin logic that intentionally needs hidden fields; never return raw trusted-repo records to users.',
1287
- security: 'Secure repos enforce the normal field visibility/projection path, including unpublished columns and relations. Trusted repos bypass that exposure boundary; if trusted access is necessary, project or sanitize the result before returning it. Authorization is still required in either path: enforce route access plus owner/tenant filters or membership checks.',
1288
- sensitiveQuerySurface: 'Filters, sort helpers, counts, and aggregate values on unpublished fields or private relations can leak information even when the value is not selected. Do not expose aggregate, _max, _min, _count, or predicate-oracle behavior over hidden fields in generated user-facing endpoints.',
1289
- mutationReturnShape: '$repos.<table>.create({ data }) and $repos.<table>.update({ id, data }) return a collection-shaped result: { data: [...], count? }. data is always an array for create/update, even for one created/updated record. If a script needs the single record object, it must read result.data[0] or result.data?.[0] ?? null.',
1290
- preferredExample: 'const result = await @REPOS.main.create({ data: @BODY }); const record = result.data?.[0] ?? null; return record;',
1291
- wrongSingleRecordAccess: 'Do not use result.data.id, do not return result.data when one object is expected, and do not assume create/update returns the bare row object.',
1292
- countPattern: 'To count records in custom code, do not fetch full rows. Use const result = await @REPOS.main.find({ fields: "id", limit: 1, meta: filter ? "filterCount" : "totalCount", ...(filter ? { filter } : {}) }); then read result.meta.filterCount or result.meta.totalCount.',
1293
- },
1294
- socketInHttpOrFlow: 'HTTP/flow context can emitToUser/emitToRoom/emitToGateway/broadcast and roomSize, but cannot reply/join/leave/disconnect/emitToCurrentRoom/broadcastToRoom because there is no bound socket. emitToRoom requires an explicit gateway path: emitToRoom(path, room, event, data). roomSize(room) counts sockets in that room across registered gateways.',
1295
- packages: 'Server packages installed through install_package are exposed as $ctx.$pkgs.packageName in server scripts.',
1296
- files: 'Upload helpers are on $storage; raw create_record on enfyra_file is not equivalent to multipart upload/storage rollback. For multipart request files, pass file: @UPLOADED_FILE to @STORAGE.$upload/@STORAGE.$update so Enfyra streams from disk-backed temp storage. Use @STORAGE.$registerFile only when the object already exists in storage and the script should create the enfyra_file record without uploading bytes. Use buffer only for small generated files.',
1297
- },
1298
- adminTesting: {
1299
- flowStep: 'Use test_flow_step or run_admin_test(kind=flow_step).',
1300
- websocket: 'Use run_admin_test(kind=websocket_event|websocket_connection).',
1301
- },
1302
- };
1303
-
1304
- return jsonContent(payload);
1305
- },
1306
- );
1307
-
1308
- // ============================================================================
1309
- // QUERY TOOLS
1310
- // ============================================================================
1311
-
1312
- server.tool(
1313
- 'get_enfyra_api_context',
1314
- [
1315
- 'Returns the resolved API base URL for this MCP session (env ENFYRA_API_URL).',
1316
- 'Use this as the cheap first target sanity check before broad discovery or mutations.',
1317
- 'Use when the user asks which HTTP endpoint or full URL applies: combine enfyraApiUrl with paths from server instructions (GET/POST /{table}, PATCH/DELETE /{table}/{id}, no GET /{table}/{id}).',
1318
- 'Auth: publicMethods on a route can allow a method without Bearer; otherwise JWT + routePermissions — see server instructions.',
1319
- 'If path might differ from table name, use get_all_routes before asserting a URL.',
1320
- 'Same mapping as MCP tool → HTTP: query_table=GET /table?..., create_record=POST /table, update_record=PATCH /table/id, delete_record=DELETE /table/id.',
1321
- 'GraphQL: see graphqlHttpUrl / graphqlSchemaUrl in response; enable per table via enfyra_graphql/update_table graphqlEnabled and send Bearer auth for table data queries. Anonymous root/schema probes may still return 200.',
1322
- ].join(' '),
1323
- {},
1324
- async () => {
1325
- const base = ENFYRA_API_URL.replace(/\/$/, '');
1326
- const gql = buildGraphqlUrls(ENFYRA_API_URL);
1327
- const payload = {
1328
- targetInstance: targetInstance(),
1329
- enfyraApiUrl: base,
1330
- graphqlHttpUrl: gql.graphqlHttpUrl,
1331
- graphqlSchemaUrl: gql.graphqlSchemaUrl,
1332
- examples: {
1333
- listOrCreate: `${base}/<table_name>`,
1334
- updateOrDelete: `${base}/<table_name>/<id>`,
1335
- oneRowById: `${base}/<table_name>?filter={"<primaryKeyFromMetadata>":{"_eq":"<id>"}}&limit=1`,
1336
- },
1337
- auth: {
1338
- publicMethods: 'If the HTTP method is public for that route, no Bearer required; else Bearer JWT and routePermissions apply.',
1339
- graphql: 'GraphQL table data requires Bearer auth; route publicMethods do not make GraphQL table data anonymous. Anonymous root/schema probes may still return 200.',
1340
- mcp: 'This server uses admin credentials from env for tools (fetchAPI).',
1341
- },
1342
- pathResolution: 'Confirm route path with get_all_routes or metadata — path may not equal table name.',
1343
- note: 'Full tool→HTTP mapping is in MCP server instructions (shown to the model at connect).',
1344
- };
1345
- return jsonContent(payload);
1346
- },
1347
- );
1348
-
1349
- server.tool('query_table', 'Query any route-backed table. Response is minimal unless fields is explicit. Every call must pass either limit or all=true.', {
1350
- tableName: z.string().describe('Table name to query'),
1351
- filter: z.string().optional().describe('Filter object as JSON string. Examples: \'{"status": {"_eq": "active"}}\''),
1352
- sort: z.string().optional().describe('Sort field. Prefix with - for descending (e.g., "createdAt", "-id")'),
1353
- page: z.number().optional().describe('Page number (default: 1)'),
1354
- limit: z.number().int().min(0).optional().describe('Items per page. Required unless all=true. Do not invent arbitrary limits for "all"; use all=true instead. Use count_records for counts.'),
1355
- all: z.boolean().optional().default(false).describe('Return all matching rows by sending REST limit=0. Use this when the user asks for all rows or a complete list.'),
1356
- fields: z.array(z.string()).optional().describe('Fields to select. If omitted, MCP selects only the table primary key to avoid oversized responses.'),
1357
- meta: z.string().optional().describe('Optional REST meta request, e.g. "totalCount", "filterCount", or aggregate modes supported by the route. Use count_records for simple counts.'),
1358
- deep: z.string().optional().describe('Optional deep relation fetch object as JSON string. Keys must be relation propertyName values.'),
1359
- aggregate: z.string().optional().describe('Optional aggregate object as JSON string, keyed by real fields/relations. Results are returned in response.meta.aggregate when supported. Do not request aggregates over hidden fields/private relations in user-facing APIs.'),
1360
- }, async ({ tableName, filter, sort, page, limit, all, fields, meta, deep, aggregate }) => {
1361
- if (!all && limit === undefined) {
1362
- throw new Error('query_table requires either limit or all=true. Do not rely on implicit default page sizes.');
1363
- }
1364
- if (all && limit !== undefined) {
1365
- throw new Error('query_table accepts either all=true or limit, not both.');
1366
- }
1367
- validateTableName(tableName);
1368
- validateFilter(filter);
1369
- parseJsonArg(deep, undefined);
1370
- parseJsonArg(aggregate, undefined);
1371
-
1372
- const queryParams = new URLSearchParams();
1373
- const selectedFields = fields && fields.length > 0 ? fields : [await getPrimaryFieldName(tableName)];
1374
- if (filter) queryParams.set('filter', filter);
1375
- if (sort) queryParams.set('sort', sort);
1376
- if (page) queryParams.set('page', String(page));
1377
- if (meta) queryParams.set('meta', meta);
1378
- if (deep) queryParams.set('deep', deep);
1379
- if (aggregate) queryParams.set('aggregate', aggregate);
1380
- const effectiveLimit = all ? 0 : limit;
1381
- queryParams.set('limit', String(effectiveLimit));
1382
- queryParams.set('fields', selectedFields.join(','));
1383
-
1384
- const query = queryParams.toString();
1385
- const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}${query ? `?${query}` : ''}`);
1386
- const payload = {
1387
- statusCode: result?.statusCode,
1388
- success: result?.success,
1389
- tableName,
1390
- fields: selectedFields,
1391
- limit: effectiveLimit,
1392
- all: !!all,
1393
- queryOptions: {
1394
- meta: meta || null,
1395
- deep: deep ? parseJsonArg(deep, null) : null,
1396
- aggregate: aggregate ? parseJsonArg(aggregate, null) : null,
1397
- },
1398
- minimalDefaultApplied: !(fields && fields.length > 0),
1399
- meta: result?.meta,
1400
- data: compactSourceFields(result?.data || [], { tableName }),
1401
- detailHint: fields && fields.length > 0
1402
- ? undefined
1403
- : 'Only the primary key was returned because fields was omitted. Re-run query_table with explicit fields for details, or use inspect_table to find valid field names.',
1404
- };
1405
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
1406
- });
1407
-
1408
- server.tool(
1409
- 'count_records',
1410
- [
1411
- 'Count records in a route-backed Enfyra table using the lightweight REST meta pattern.',
1412
- 'Without filter it requests fields=id&limit=1&meta=totalCount and returns meta.totalCount.',
1413
- 'With filter it requests fields=id&limit=1&meta=filterCount and returns meta.filterCount.',
1414
- 'Use this instead of fetching rows when the user only needs a count.',
1415
- ].join(' '),
1416
- {
1417
- tableName: z.string().describe('Table name to count. Must have a REST route.'),
1418
- filter: z.string().optional().describe('Optional Query DSL filter as JSON string. Example: \'{"status":{"_eq":"active"}}\''),
1419
- },
1420
- async ({ tableName, filter }) => {
1421
- validateTableName(tableName);
1422
- validateFilter(filter);
1423
-
1424
- const metaField = filter ? 'filterCount' : 'totalCount';
1425
- const queryParams = new URLSearchParams();
1426
- queryParams.set('fields', 'id');
1427
- queryParams.set('limit', '1');
1428
- queryParams.set('meta', metaField);
1429
- if (filter) queryParams.set('filter', filter);
1430
-
1431
- const result = await fetchAPI(ENFYRA_API_URL, `/${tableName}?${queryParams.toString()}`);
1432
- const meta = result?.meta || {};
1433
- const hasCount = Object.prototype.hasOwnProperty.call(meta, metaField);
1434
- const count = hasCount ? Number(meta[metaField]) : null;
1435
- const payload = {
1436
- tableName,
1437
- count,
1438
- countField: metaField,
1439
- filterApplied: !!filter,
1440
- meta,
1441
- request: {
1442
- path: `/${tableName}`,
1443
- query: Object.fromEntries(queryParams.entries()),
1444
- },
1445
- warning: hasCount ? undefined : `Response meta did not include ${metaField}.`,
1446
- };
1447
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
1448
- },
1449
- );
1450
-
1451
- server.tool(
1452
- 'find_one_record',
1453
- 'Find a single record by ID or filter. By ID uses GET with filter (Enfyra has no GET /table/:id route).',
1454
- {
1455
- tableName: z.string().describe('Table name'),
1456
- id: z.string().optional().describe('Record ID'),
1457
- filter: z.string().optional().describe('Filter as JSON string to find by'),
1458
- fields: z.array(z.string()).optional().describe('Fields to select. If omitted, returns only the primary key.'),
1459
- },
1460
- async ({ tableName, id, filter, fields }) => {
1461
- validateTableName(tableName);
1462
- const primaryKey = await getPrimaryFieldName(tableName);
1463
- const selectedFields = fields && fields.length > 0 ? fields : [primaryKey];
1464
- if (id) {
1465
- // Enfyra route engine does not register GET /<table>/:id (only PATCH/DELETE use /:id). Use list + filter.
1466
- const filterObj = JSON.stringify({ [primaryKey]: { _eq: id } });
1467
- const queryParams = new URLSearchParams({
1468
- filter: filterObj,
1469
- limit: '1',
1470
- fields: selectedFields.join(','),
1471
- });
1472
- const result = await fetchAPI(
1473
- ENFYRA_API_URL,
1474
- `/${tableName}?${queryParams.toString()}`,
1475
- );
1476
- const one = result.data?.[0] ?? null;
1477
- return { content: [{ type: 'text', text: JSON.stringify({
1478
- tableName,
1479
- primaryKey,
1480
- fields: selectedFields,
1481
- data: compactSourceFields(one, { tableName }),
1482
- detailHint: fields && fields.length > 0 ? undefined : 'Only the primary key was returned. Pass fields for details.',
1483
- }, null, 2) }] };
1484
- }
1485
- if (!filter) throw new Error('Provide id or filter');
1486
- validateFilter(filter);
1487
- const queryParams = new URLSearchParams({
1488
- filter,
1489
- limit: '1',
1490
- fields: selectedFields.join(','),
1491
- });
1492
- const result = await fetchAPI(
1493
- ENFYRA_API_URL,
1494
- `/${tableName}?${queryParams.toString()}`,
1495
- );
1496
- return { content: [{ type: 'text', text: JSON.stringify({
1497
- tableName,
1498
- fields: selectedFields,
1499
- data: compactSourceFields(result.data?.[0] || null, { tableName }),
1500
- detailHint: fields && fields.length > 0 ? undefined : 'Only the primary key was returned. Pass fields for details.',
1501
- }, null, 2) }] };
1502
- },
1503
- );
1504
-
1505
- // ============================================================================
1506
- // CRUD TOOLS
1507
- // ============================================================================
1508
-
1509
- server.tool('create_record', 'Create a new record in any route-backed table. The tool validates body keys against live metadata, validates sourceCode before saving script-backed records, and validates enfyra_extension.code before saving extension records.', {
1510
- tableName: z.string().describe('Table name to insert into'),
1511
- data: z.string().describe('Record data as JSON string'),
1512
- queryParams: z.string().optional().describe('Optional query params as JSON object string, e.g. {"expired_at":"2026-09-20"}. Use for route contracts that intentionally keep workflow fields out of the validated body.'),
1513
- globalRulesAckKey: globalRulesAckParam(z),
1514
- knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required only when data contains sourceCode. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1515
- extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required only when tableName is enfyra_extension and data contains code. Use extensionAckKey from get_enfyra_required_knowledge.'),
1516
- }, async ({ tableName, data, queryParams, globalRulesAckKey, knowledgeAckKey, extensionKnowledgeAckKey }) => {
1517
- assertGlobalRulesAck(globalRulesAckKey);
1518
- validateTableName(tableName);
1519
- assertKnowledgeForGenericMutation(tableName, data, { knowledgeAckKey, extensionKnowledgeAckKey });
1520
- const prepared = await prepareGenericMutation(tableName, data);
1521
- const extensionValidation = await validateExtensionCodeForGenericMutation(tableName, prepared.payload, prepared.payload?.name);
1522
- const query = parseQueryParamsArg(queryParams);
1523
- const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}`, query), { method: 'POST', body: JSON.stringify(prepared.payload) });
1524
- return { content: [{ type: 'text', text: JSON.stringify({
1525
- ...summarizeMutationResult(result, 'created', tableName),
1526
- scriptValidation: prepared.scriptValidation,
1527
- extensionValidation,
1528
- }, null, 2) }] };
1529
- });
1530
-
1531
- server.tool('update_record', 'Update an existing record by ID using PATCH. The tool validates body keys against live metadata, validates sourceCode before saving script-backed records, and validates enfyra_extension.code before saving extension records. Prefer update_extension_code for normal extension edits.', {
1532
- tableName: z.string().describe('Table name'),
1533
- id: z.string().describe('Record ID to update'),
1534
- data: z.string().describe('Fields to update as JSON string'),
1535
- queryParams: z.string().optional().describe('Optional query params as JSON object string for route contracts that intentionally keep workflow fields out of the validated body.'),
1536
- globalRulesAckKey: globalRulesAckParam(z),
1537
- knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required only when data contains sourceCode. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1538
- extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required only when tableName is enfyra_extension and data contains code. Use extensionAckKey from get_enfyra_required_knowledge.'),
1539
- }, async ({ tableName, id, data, queryParams, globalRulesAckKey, knowledgeAckKey, extensionKnowledgeAckKey }) => {
1540
- assertGlobalRulesAck(globalRulesAckKey);
1541
- validateTableName(tableName);
1542
- assertKnowledgeForGenericMutation(tableName, data, { knowledgeAckKey, extensionKnowledgeAckKey });
1543
- const prepared = await prepareGenericMutation(tableName, data);
1544
- const extensionValidation = await validateExtensionCodeForGenericMutation(tableName, prepared.payload, id);
1545
- const query = parseQueryParamsArg(queryParams);
1546
- const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}/${id}`, query), { method: 'PATCH', body: JSON.stringify(prepared.payload) });
1547
- return { content: [{ type: 'text', text: JSON.stringify({
1548
- ...summarizeMutationResult(result, 'updated', tableName),
1549
- scriptValidation: prepared.scriptValidation,
1550
- extensionValidation,
1551
- }, null, 2) }] };
1552
- });
1553
-
1554
- server.tool(
1555
- 'get_script_source',
1556
- [
1557
- 'Fetch the full editable source for one script-backed metadata record without preview truncation.',
1558
- 'Use this before reviewing or patching long handlers, hooks, flow steps, websocket scripts, GraphQL scripts, or bootstrap scripts.',
1559
- ].join(' '),
1560
- {
1561
- tableName: z.enum(SCRIPT_BACKED_TABLES).describe('Script-backed table to read'),
1562
- id: z.string().describe('Record ID to read'),
1563
- },
1564
- async ({ tableName, id }) => {
1565
- const { primaryKey, record, sourceField, sourceCode } = await fetchScriptRecord(tableName, id);
1566
- const sourceArtifact = writeSourceArtifact({ tableName, id, fieldName: sourceField, source: sourceCode });
1567
- return { content: [{ type: 'text', text: JSON.stringify({
1568
- tableName,
1569
- id,
1570
- primaryKey,
1571
- sourceField,
1572
- sourceFile: sourceArtifact.tmpFile,
1573
- sourcePreview: sourceArtifact.preview,
1574
- sourceLength: sourceCode.length,
1575
- sourceSha256: sha256(sourceCode),
1576
- scriptLanguage: record.scriptLanguage || record.language || null,
1577
- record: scriptRecordLabel(tableName, record),
1578
- }, null, 2) }] };
1579
- },
1580
- );
1581
-
1582
- server.tool(
1583
- 'patch_script_source',
1584
- [
1585
- 'Patch sourceCode on a script-backed record using exact search/replace with optional hash checking.',
1586
- 'By default this returns a preview only. Set apply=true to validate through /admin/script/validate and save.',
1587
- 'Use get_script_source first for long scripts, then patch only the exact block you intend to change.',
1588
- ].join(' '),
1589
- {
1590
- tableName: z.enum(SCRIPT_BACKED_TABLES).describe('Script-backed table to patch'),
1591
- id: z.string().describe('Record ID to patch'),
1592
- oldText: z.string().describe('Exact text to replace'),
1593
- newText: z.string().describe('Replacement text'),
1594
- occurrence: z.enum(['first', 'all']).optional().default('all').describe('Replace first occurrence or all occurrences.'),
1595
- expectedSourceSha256: z.string().optional().describe('Optional SHA-256 from get_script_source; fails if source changed.'),
1596
- scriptLanguage: z.string().optional().describe('Script language to save. Defaults to existing scriptLanguage or javascript.'),
1597
- apply: z.boolean().optional().default(false).describe('false returns preview only; true validates and saves.'),
1598
- globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when apply=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
1599
- knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required when apply=true. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1600
- },
1601
- async ({ tableName, id, oldText, newText, occurrence, expectedSourceSha256, scriptLanguage, apply, globalRulesAckKey, knowledgeAckKey }) => {
1602
- const { record, sourceField, sourceCode } = await fetchScriptRecord(tableName, id);
1603
- if (sourceField !== 'sourceCode') {
1604
- throw new Error(`patch_script_source only saves sourceCode records. Record uses "${sourceField}"; use update_record intentionally for this legacy field.`);
1605
- }
1606
- const beforeHash = sha256(sourceCode);
1607
- if (expectedSourceSha256 && expectedSourceSha256 !== beforeHash) {
1608
- throw new Error(`Source hash mismatch. Current sha256 is ${beforeHash}; re-read with get_script_source before patching.`);
1609
- }
1610
- const { occurrences, patched, replaced } = replaceOccurrence(sourceCode, oldText, newText, occurrence || 'all');
1611
- const afterHash = sha256(patched);
1612
- const payload = {
1613
- action: apply ? 'patch_script_source_applied' : 'patch_script_source_preview',
1614
- tableName,
1615
- id,
1616
- sourceField,
1617
- sourceLengthBefore: sourceCode.length,
1618
- sourceLengthAfter: patched.length,
1619
- sourceSha256Before: beforeHash,
1620
- sourceSha256After: afterHash,
1621
- occurrences,
1622
- replaced,
1623
- preview: {
1624
- before: sourcePreview(sourceCode, oldText),
1625
- after: sourcePreview(patched, newText),
1626
- },
1627
- next: apply ? undefined : 'Call patch_script_source again with apply=true and expectedSourceSha256 set to sourceSha256Before to validate and save.',
1628
- };
1629
- if (!apply) {
1630
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
1631
- }
1632
- assertGlobalRulesAck(globalRulesAckKey);
1633
- assertDynamicCodeKnowledgeAck(knowledgeAckKey);
1634
- const language = scriptLanguage || record.scriptLanguage || 'javascript';
1635
- const prepared = await prepareGenericMutation(
1636
- tableName,
1637
- JSON.stringify({ sourceCode: patched, scriptLanguage: language }),
1638
- );
1639
- const result = await fetchAPI(
1640
- ENFYRA_API_URL,
1641
- `/${tableName}/${encodeURIComponent(String(id))}`,
1642
- { method: 'PATCH', body: JSON.stringify(prepared.payload) },
1643
- );
1644
- return { content: [{ type: 'text', text: JSON.stringify({
1645
- ...payload,
1646
- ...summarizeMutationResult(result, 'patch_script_source_applied', tableName),
1647
- id,
1648
- scriptLanguage: language,
1649
- scriptValidation: prepared.scriptValidation,
1650
- }, null, 2) }] };
1651
- },
1652
- );
1653
-
1654
- server.tool(
1655
- 'update_script_source',
1656
- [
1657
- 'Update sourceCode on a script-backed record without forcing the caller to JSON-escape long code.',
1658
- 'Use this for enfyra_flow_step, enfyra_route_handler, enfyra_pre_hook, enfyra_post_hook, enfyra_websocket_event, enfyra_websocket, enfyra_graphql, and enfyra_bootstrap_script.',
1659
- 'The tool validates sourceCode through /admin/script/validate before saving and never accepts compiledCode.',
1660
- ].join(' '),
1661
- {
1662
- tableName: z.enum([
1663
- 'enfyra_route_handler',
1664
- 'enfyra_pre_hook',
1665
- 'enfyra_post_hook',
1666
- 'enfyra_flow_step',
1667
- 'enfyra_websocket_event',
1668
- 'enfyra_websocket',
1669
- 'enfyra_graphql',
1670
- 'enfyra_bootstrap_script',
1671
- ]).describe('Script-backed table to update'),
1672
- id: z.string().describe('Record ID to update'),
1673
- sourceCode: z.string().describe('Editable script sourceCode. Pass the raw code string; do not JSON-escape it yourself.'),
1674
- scriptLanguage: z.string().optional().default('javascript').describe('Script language, usually javascript or typescript'),
1675
- globalRulesAckKey: globalRulesAckParam(z),
1676
- knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
1677
- },
1678
- async ({ tableName, id, sourceCode, scriptLanguage, globalRulesAckKey, knowledgeAckKey }) => {
1679
- assertGlobalRulesAck(globalRulesAckKey);
1680
- assertDynamicCodeKnowledgeAck(knowledgeAckKey);
1681
- validateTableName(tableName);
1682
- const prepared = await prepareGenericMutation(
1683
- tableName,
1684
- JSON.stringify({ sourceCode, scriptLanguage }),
1685
- );
1686
- const result = await fetchAPI(
1687
- ENFYRA_API_URL,
1688
- `/${tableName}/${encodeURIComponent(String(id))}`,
1689
- { method: 'PATCH', body: JSON.stringify(prepared.payload) },
1690
- );
1691
- return { content: [{ type: 'text', text: JSON.stringify({
1692
- ...summarizeMutationResult(result, 'updated_script_source', tableName),
1693
- id,
1694
- sourceLength: sourceCode.length,
1695
- scriptLanguage,
1696
- scriptValidation: prepared.scriptValidation,
1697
- }, null, 2) }] };
1698
- },
1699
- );
1700
-
1701
- server.tool('delete_record', 'Delete a record by ID', {
1702
- tableName: z.string().describe('Table name'),
1703
- id: z.string().describe('Record ID to delete'),
1704
- queryParams: z.string().optional().describe('Optional query params as JSON object string for route-specific confirmation contracts.'),
1705
- confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
1706
- globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when confirm=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
1707
- }, async ({ tableName, id, queryParams, confirm, globalRulesAckKey }) => {
1708
- validateTableName(tableName);
1709
- const primaryKey = await getPrimaryFieldName(tableName);
1710
- if (!confirm) {
1711
- const query = new URLSearchParams({
1712
- filter: JSON.stringify({ [primaryKey]: { _eq: id } }),
1713
- limit: '1',
1714
- fields: primaryKey,
1715
- });
1716
- const preview = await fetchAPI(ENFYRA_API_URL, `/${tableName}?${query.toString()}`).catch((error) => ({ error: String(error?.message || error) }));
1717
- return { content: [{ type: 'text', text: JSON.stringify({
1718
- action: 'delete_record_preview',
1719
- tableName,
1720
- id,
1721
- primaryKey,
1722
- preview: preview?.data?.[0] || null,
1723
- previewError: preview?.error,
1724
- destructive: true,
1725
- next: 'Call delete_record again with confirm=true to delete this route-backed record.',
1726
- }, null, 2) }] };
1727
- }
1728
- assertGlobalRulesAck(globalRulesAckKey);
1729
- const query = parseQueryParamsArg(queryParams);
1730
- const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}/${id}`, query), { method: 'DELETE' });
1731
- return { content: [{ type: 'text', text: JSON.stringify({
1732
- action: 'deleted',
1733
- tableName,
1734
- id,
1735
- statusCode: result?.statusCode,
1736
- success: result?.success,
1737
- }, null, 2) }] };
1738
- });
1739
-
1740
- server.tool(
1741
- 'list_methods',
1742
- 'List enfyra_method records with their UI colors. Use this before creating route methods or method-colored UI.',
1743
- {},
1744
- async () => {
1745
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_method?fields=id,_id,name,buttonColor,textColor,isSystem&sort=name&limit=0');
1746
- const methods = unwrapData(result).map((method) => ({
1747
- id: getId(method),
1748
- name: method.name,
1749
- buttonColor: method.buttonColor,
1750
- textColor: method.textColor,
1751
- isSystem: method.isSystem === true,
1752
- }));
1753
- return { content: [{ type: 'text', text: JSON.stringify({
1754
- tableName: 'enfyra_method',
1755
- methods,
1756
- appUi: '/settings/methods',
1757
- }, null, 2) }] };
1758
- },
1759
- );
1760
-
1761
- server.tool(
1762
- 'create_method',
1763
- 'Create a enfyra_method record with app badge colors. Prefer this over generic create_record for enfyra_method.',
1764
- {
1765
- method: z.string().describe('Uppercase method name, e.g. GET, POST, PUT, CUSTOM_METHOD. Must start with A-Z and contain only A-Z, 0-9, or underscore.'),
1766
- buttonColor: z.string().describe('Badge background color as full hex, e.g. #dbeafe.'),
1767
- textColor: z.string().describe('Badge text color as full hex, e.g. #1d4ed8.'),
1768
- isSystem: z.boolean().optional().default(false).describe('Set true only for built-in/runtime-owned methods. Normal app methods should leave this false.'),
1769
- globalRulesAckKey: globalRulesAckParam(z),
1770
- },
1771
- async ({ method, buttonColor, textColor, isSystem, globalRulesAckKey }) => {
1772
- assertGlobalRulesAck(globalRulesAckKey);
1773
- const normalizedMethod = normalizeMethodNameInput(method);
1774
- const existing = await findMethodRecordByName(normalizedMethod);
1775
- if (existing) {
1776
- throw new Error(`Method ${normalizedMethod} already exists with id ${getId(existing)}. Use update_method to change colors.`);
1777
- }
1778
- const body = {
1779
- name: normalizedMethod,
1780
- buttonColor: normalizeHexColorInput(buttonColor, 'buttonColor'),
1781
- textColor: normalizeHexColorInput(textColor, 'textColor'),
1782
- isSystem: isSystem === true,
1783
- };
1784
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_method', {
1785
- method: 'POST',
1786
- body: JSON.stringify(body),
1787
- });
1788
- _methodMap = null;
1789
- return { content: [{ type: 'text', text: JSON.stringify({
1790
- ...summarizeMutationResult(result, 'created', 'enfyra_method'),
1791
- name: normalizedMethod,
1792
- appUi: '/settings/methods',
1793
- }, null, 2) }] };
1794
- },
1795
- );
1796
-
1797
- server.tool(
1798
- 'update_method',
1799
- 'Update a enfyra_method record color pair, and optionally rename non-system methods. Prefer this over generic update_record for enfyra_method.',
1800
- {
1801
- id: z.string().optional().describe('Method record id. If omitted, method is used to find the record.'),
1802
- method: z.string().optional().describe('Existing method name to find, or new name when id is provided.'),
1803
- buttonColor: z.string().optional().describe('Badge background color as full hex, e.g. #dbeafe.'),
1804
- textColor: z.string().optional().describe('Badge text color as full hex, e.g. #1d4ed8.'),
1805
- globalRulesAckKey: globalRulesAckParam(z),
1806
- },
1807
- async ({ id, method, buttonColor, textColor, globalRulesAckKey }) => {
1808
- assertGlobalRulesAck(globalRulesAckKey);
1809
- let targetId = id;
1810
- let existing = null;
1811
- if (!targetId) {
1812
- if (!method) throw new Error('Provide id or method.');
1813
- const normalizedMethod = normalizeMethodNameInput(method);
1814
- existing = await findMethodRecordByName(normalizedMethod);
1815
- if (!existing) throw new Error(`Method ${normalizedMethod} was not found.`);
1816
- targetId = getId(existing);
1817
- }
1818
-
1819
- const body = {};
1820
- if (buttonColor !== undefined) {
1821
- body.buttonColor = normalizeHexColorInput(buttonColor, 'buttonColor');
1822
- }
1823
- if (textColor !== undefined) {
1824
- body.textColor = normalizeHexColorInput(textColor, 'textColor');
1825
- }
1826
- if (method !== undefined && id) {
1827
- body.name = normalizeMethodNameInput(method);
1828
- }
1829
- if (Object.keys(body).length === 0) {
1830
- throw new Error('Provide buttonColor, textColor, or a new method name.');
1831
- }
1832
-
1833
- const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_method/${encodeURIComponent(String(targetId))}`, {
1834
- method: 'PATCH',
1835
- body: JSON.stringify(body),
1836
- });
1837
- _methodMap = null;
1838
- return { content: [{ type: 'text', text: JSON.stringify({
1839
- ...summarizeMutationResult(result, 'updated', 'enfyra_method'),
1840
- id: targetId,
1841
- appUi: '/settings/methods',
1842
- }, null, 2) }] };
1843
- },
1844
- );
1845
-
1846
- server.tool(
1847
- 'delete_method',
1848
- 'Preview or delete a enfyra_method record. Only delete unused custom methods; system/default methods should be kept.',
1849
- {
1850
- id: z.string().optional().describe('Method record id. If omitted, method is used to find the record.'),
1851
- method: z.string().optional().describe('Method name to find when id is omitted.'),
1852
- confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
1853
- globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when confirm=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
1854
- },
1855
- async ({ id, method, confirm, globalRulesAckKey }) => {
1856
- let targetId = id;
1857
- let target = null;
1858
- if (!targetId) {
1859
- if (!method) throw new Error('Provide id or method.');
1860
- target = await findMethodRecordByName(normalizeMethodNameInput(method));
1861
- if (!target) throw new Error(`Method ${method} was not found.`);
1862
- targetId = getId(target);
1863
- }
1864
- if (!confirm) {
1865
- if (!target) {
1866
- const primaryKey = await getPrimaryFieldName('enfyra_method');
1867
- const filter = encodeURIComponent(JSON.stringify({ [primaryKey]: { _eq: targetId } }));
1868
- const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_method?filter=${filter}&limit=1&fields=id,_id,name,buttonColor,textColor,isSystem`);
1869
- target = unwrapData(result)[0] || null;
1870
- }
1871
- return { content: [{ type: 'text', text: JSON.stringify({
1872
- action: 'delete_method_preview',
1873
- id: targetId,
1874
- name: target?.name,
1875
- isSystem: target?.isSystem === true,
1876
- destructive: true,
1877
- warning: 'Only delete unused custom methods. Deleting a method can affect route method relations.',
1878
- next: 'Call delete_method again with confirm=true to delete.',
1879
- }, null, 2) }] };
1880
- }
1881
- assertGlobalRulesAck(globalRulesAckKey);
1882
- const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_method/${encodeURIComponent(String(targetId))}`, { method: 'DELETE' });
1883
- _methodMap = null;
1884
- return { content: [{ type: 'text', text: JSON.stringify({
1885
- action: 'deleted',
1886
- tableName: 'enfyra_method',
1887
- id: targetId,
1888
- statusCode: result?.statusCode,
1889
- success: result?.success,
1890
- }, null, 2) }] };
1891
- },
1892
- );
1893
-
1894
- server.tool(
1895
- 'run_admin_test',
1896
- [
1897
- 'Run an Enfyra admin test without saving metadata. Wraps POST /admin/test/run.',
1898
- 'Kinds: flow_step, websocket_event, websocket_connection. Use this to validate flow/websocket script behavior before creating records.',
1899
- ].join(' '),
1900
- {
1901
- kind: z.enum(['flow_step', 'websocket_event', 'websocket_connection']).describe('Admin test kind'),
1902
- body: z.string().describe('JSON body for the test. Include type/config for flow_step or script/gatewayPath/eventName/payload for websocket tests. Do not include kind; the tool adds it.'),
1903
- },
1904
- async ({ kind, body }) => {
1905
- const parsed = body ? JSON.parse(body) : {};
1906
- const result = await fetchAPI(ENFYRA_API_URL, '/admin/test/run', {
1907
- method: 'POST',
1908
- body: JSON.stringify({ ...parsed, kind }),
1909
- });
1910
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1911
- },
1912
- );
1913
-
1914
- server.tool(
1915
- 'test_flow_step',
1916
- 'Test a single flow step without saving it. Wraps POST /admin/test/run with kind=flow_step.',
1917
- {
1918
- type: z.enum(['script', 'condition', 'query', 'create', 'update', 'delete', 'http', 'trigger_flow', 'sleep', 'log']).describe('Flow step type'),
1919
- config: z.string().describe('Step config as JSON string'),
1920
- timeout: z.number().optional().describe('Timeout in ms'),
1921
- key: z.string().optional().describe('Optional step key for mock flow context'),
1922
- mockFlow: z.string().optional().describe('Optional mockFlow JSON object'),
1923
- },
1924
- async ({ type, config, timeout, key, mockFlow }) => {
1925
- const body = {
1926
- type,
1927
- config: JSON.parse(config),
1928
- ...(timeout ? { timeout } : {}),
1929
- ...(key ? { key } : {}),
1930
- ...(mockFlow ? { mockFlow: JSON.parse(mockFlow) } : {}),
1931
- };
1932
- const result = await fetchAPI(ENFYRA_API_URL, '/admin/test/run', {
1933
- method: 'POST',
1934
- body: JSON.stringify({ ...body, kind: 'flow_step' }),
1935
- });
1936
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1937
- },
1938
- );
1939
-
1940
- server.tool(
1941
- 'trigger_flow',
1942
- 'Trigger a saved flow by id or name. Wraps POST /admin/flow/trigger/:id.',
1943
- {
1944
- flowIdOrName: z.union([z.string(), z.number()]).describe('Flow id or name accepted by FlowService.trigger'),
1945
- payload: z.string().optional().describe('Payload JSON object. Default {}.'),
1946
- },
1947
- async ({ flowIdOrName, payload }) => {
1948
- const result = await fetchAPI(ENFYRA_API_URL, `/admin/flow/trigger/${encodeURIComponent(String(flowIdOrName))}`, {
1949
- method: 'POST',
1950
- body: JSON.stringify({ payload: payload ? JSON.parse(payload) : {} }),
1951
- });
1952
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1953
- },
1954
- );
1955
-
1956
- // ============================================================================
1957
- // ROUTE & HANDLER TOOLS
1958
- // ============================================================================
1959
-
1960
- let _methodMap = null;
1961
- async function getMethodMap() {
1962
- if (_methodMap) return _methodMap;
1963
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_method?limit=0');
1964
- _methodMap = {};
1965
- for (const m of result.data) {
1966
- _methodMap[m.name] = m.id || m._id;
1967
- }
1968
- return _methodMap;
1969
- }
1970
-
1971
- function resolveMethodIds(methodMap, names) {
1972
- return names.map(m => {
1973
- const id = methodMap[m.toUpperCase()];
1974
- if (!id) throw new Error(`Unknown method "${m}". Valid: ${Object.keys(methodMap).join(', ')}`);
1975
- return { id };
1976
- });
1977
- }
1978
-
1979
- async function getMethodIdNameMap() {
1980
- const methodMap = await getMethodMap();
1981
- return Object.fromEntries(Object.entries(methodMap).map(([method, id]) => [String(id), method]));
1982
- }
1983
-
1984
- function withMethodNames(records, methodIdNameMap, field = 'methods') {
1985
- return records.map((record) => ({
1986
- ...record,
1987
- [field]: Array.isArray(record?.[field])
1988
- ? record[field].map((item) => ({
1989
- ...item,
1990
- name: item.name || methodIdNameMap[String(getId(item))] || null,
1991
- }))
1992
- : record?.[field],
1993
- }));
1994
- }
1995
-
1996
- async function collectRestDefinitionState() {
1997
- await getValidToken();
1998
- const [
1999
- metadataContext,
2000
- routes,
2001
- handlers,
2002
- preHooks,
2003
- postHooks,
2004
- routePermissions,
2005
- guards,
2006
- guardRules,
2007
- fieldPermissions,
2008
- columnRules,
2009
- methodIdNameMap,
2010
- ] = await Promise.all([
2011
- getMetadataTables(),
2012
- fetchAll('/enfyra_route?limit=1000'),
2013
- fetchAll('/enfyra_route_handler?limit=1000'),
2014
- fetchAll('/enfyra_pre_hook?limit=1000'),
2015
- fetchAll('/enfyra_post_hook?limit=1000'),
2016
- fetchAll('/enfyra_route_permission?limit=1000'),
2017
- fetchAll('/enfyra_guard?limit=1000'),
2018
- fetchAll('/enfyra_guard_rule?limit=1000'),
2019
- fetchAll('/enfyra_field_permission?limit=1000'),
2020
- fetchAll('/enfyra_column_rule?limit=1000'),
2021
- getMethodIdNameMap(),
2022
- ]);
2023
-
2024
- return {
2025
- ...metadataContext,
2026
- routes,
2027
- handlers,
2028
- preHooks,
2029
- postHooks,
2030
- routePermissions,
2031
- guards,
2032
- guardRules,
2033
- fieldPermissions,
2034
- columnRules,
2035
- methodIdNameMap,
2036
- };
2037
- }
2038
-
2039
- async function collectFeatureSearchState() {
2040
- const metadata = await discoveryFetch('/metadata');
2041
- const routesResult = await discoveryFetch('/enfyra_route?limit=500');
2042
- const handlersResult = await discoveryFetch('/enfyra_route_handler?limit=500');
2043
- const preHooksResult = await discoveryFetch('/enfyra_pre_hook?limit=500');
2044
- const postHooksResult = await discoveryFetch('/enfyra_post_hook?limit=500');
2045
- const routePermissionsResult = await discoveryFetch('/enfyra_route_permission?limit=500');
2046
- const guardsResult = await discoveryFetch('/enfyra_guard?limit=500');
2047
- const guardRulesResult = await discoveryFetch('/enfyra_guard_rule?limit=500');
2048
- const fieldPermissionsResult = await discoveryFetch('/enfyra_field_permission?limit=500');
2049
- const columnRulesResult = await discoveryFetch('/enfyra_column_rule?limit=500');
2050
- const methodsResult = await discoveryFetch('/enfyra_method?limit=100');
2051
- const methodIdNameMap = Object.fromEntries(
2052
- unwrapData(methodsResult).map((method) => [String(getId(method)), method.name]),
2053
- );
2054
-
2055
- return {
2056
- metadata,
2057
- tables: normalizeTables(metadata),
2058
- routes: unwrapData(routesResult),
2059
- handlers: unwrapData(handlersResult),
2060
- preHooks: unwrapData(preHooksResult),
2061
- postHooks: unwrapData(postHooksResult),
2062
- routePermissions: unwrapData(routePermissionsResult),
2063
- guards: unwrapData(guardsResult),
2064
- guardRules: unwrapData(guardRulesResult),
2065
- fieldPermissions: unwrapData(fieldPermissionsResult),
2066
- columnRules: unwrapData(columnRulesResult),
2067
- methodIdNameMap,
2068
- partialErrors: collectPartialErrors({
2069
- metadata,
2070
- routesResult,
2071
- handlersResult,
2072
- preHooksResult,
2073
- postHooksResult,
2074
- routePermissionsResult,
2075
- guardsResult,
2076
- guardRulesResult,
2077
- fieldPermissionsResult,
2078
- columnRulesResult,
2079
- methodsResult,
2080
- }),
2081
- };
2082
- }
2083
-
2084
- function enrichRoute(route, state) {
2085
- const routeId = getId(route);
2086
- const routeHandlers = state.handlers
2087
- .filter((item) => sameId(refId(item.route), routeId))
2088
- .map((item) => pickCodeSummary({
2089
- ...item,
2090
- method: item.method ? {
2091
- ...item.method,
2092
- name: state.methodIdNameMap[String(getId(item.method))] || item.method.name || null,
2093
- } : item.method,
2094
- }, 'sourceCode'));
2095
- const routePreHooks = withMethodNames(
2096
- state.preHooks.filter((item) => item.isGlobal || sameId(refId(item.route), routeId)),
2097
- state.methodIdNameMap,
2098
- ).map((item) => pickCodeSummary(item, 'code'));
2099
- const routePostHooks = withMethodNames(
2100
- state.postHooks.filter((item) => item.isGlobal || sameId(refId(item.route), routeId)),
2101
- state.methodIdNameMap,
2102
- ).map((item) => pickCodeSummary(item, 'code'));
2103
- const routePermissions = withMethodNames(
2104
- state.routePermissions.filter((item) => sameId(refId(item.route), routeId)),
2105
- state.methodIdNameMap,
2106
- );
2107
- const routeGuards = withMethodNames(
2108
- state.guards.filter((item) => item.isGlobal || sameId(refId(item.route), routeId)),
2109
- state.methodIdNameMap,
2110
- ).map((guard) => ({
2111
- ...guard,
2112
- rules: state.guardRules.filter((rule) => sameId(refId(rule.guard), getId(guard))),
2113
- }));
2114
-
2115
- return {
2116
- ...route,
2117
- availableMethods: Array.isArray(route.availableMethods)
2118
- ? route.availableMethods.map((method) => ({
2119
- ...method,
2120
- name: method.name || state.methodIdNameMap[String(getId(method))] || null,
2121
- }))
2122
- : route.availableMethods,
2123
- publicMethods: Array.isArray(route.publicMethods)
2124
- ? route.publicMethods.map((method) => ({
2125
- ...method,
2126
- name: method.name || state.methodIdNameMap[String(getId(method))] || null,
2127
- }))
2128
- : route.publicMethods,
2129
- skipRoleGuardMethods: Array.isArray(route.skipRoleGuardMethods)
2130
- ? route.skipRoleGuardMethods.map((method) => ({
2131
- ...method,
2132
- name: method.name || state.methodIdNameMap[String(getId(method))] || null,
2133
- }))
2134
- : route.skipRoleGuardMethods,
2135
- handlers: routeHandlers,
2136
- preHooks: routePreHooks,
2137
- postHooks: routePostHooks,
2138
- routePermissions,
2139
- guards: routeGuards,
2140
- };
2141
- }
2142
-
2143
- server.tool(
2144
- 'inspect_table',
2145
- [
2146
- 'REST-first inspection for one table. Use before writing code, filters, permissions, validation, or routes for a table.',
2147
- 'Returns columns, relations, route-backed REST paths, route handlers/hooks/guards/permissions, field permissions, and column validation rules.',
2148
- ].join(' '),
2149
- {
2150
- tableName: z.string().describe('Table name or alias to inspect'),
2151
- },
2152
- async ({ tableName }) => {
2153
- const state = await collectRestDefinitionState();
2154
- const table = state.tables.find((item) => item?.name === tableName || item?.alias === tableName);
2155
- if (!table) {
2156
- throw new Error(`Unknown table "${tableName}". Use get_all_tables({ search, limit }) or get_all_metadata({ search, all: true }) to confirm the table name. If a just-created table is missing, verify the create response/reload event before calling manual reload tools.`);
2157
- }
2158
- const tableId = getId(table);
2159
- const columnIds = new Set((table.columns || []).map((column) => String(getId(column))));
2160
- const relationIds = new Set((table.relations || []).map((relation) => String(getId(relation))));
2161
- const routes = state.routes.filter((route) => sameId(refId(route.mainTable), tableId));
2162
-
2163
- const payload = {
2164
- table: summarizeTable(table),
2165
- database: getMetadataDatabaseContext(state.metadata, state.tables),
2166
- rest: {
2167
- routePattern: 'GET/POST /<path>; PATCH/DELETE /<path>/:id; no dynamic GET /<path>/:id.',
2168
- routes: routes.map((route) => enrichRoute(route, state)),
2169
- routeBacked: routes.length > 0,
2170
- },
2171
- validation: {
2172
- validateBody: table.validateBody,
2173
- columnRules: state.columnRules.filter((rule) => columnIds.has(String(refId(rule.column)))),
2174
- },
2175
- permissions: {
2176
- fieldPermissions: state.fieldPermissions.filter((permission) => (
2177
- permission.column && columnIds.has(String(refId(permission.column)))
2178
- ) || (
2179
- permission.relation && relationIds.has(String(refId(permission.relation)))
2180
- )),
2181
- },
2182
- queryGuidance: {
2183
- fields: 'Use column names and relation propertyName values.',
2184
- filter: 'Use query DSL operators on column names or nested relation propertyName objects.',
2185
- deep: 'Deep fetch keys are relation propertyName values.',
2186
- relationMutation: 'For relation schema creation/update use targetTable/type/propertyName/inversePropertyName|mappedBy/isNullable/onDelete only. Do not provide physical FK/junction columns; Enfyra derives and hides them. Omit inversePropertyName unless a concrete response, UI, deep query, aggregate sort/count, or parent-to-child traversal needs it.',
2187
- },
2188
- };
2189
-
2190
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
2191
- },
2192
- );
2193
-
2194
- server.tool(
2195
- 'inspect_route',
2196
- [
2197
- 'REST-first inspection for a route/path. Use before changing handlers, hooks, permissions, guards, or testing an endpoint.',
2198
- 'Returns the backing table, available/public methods, handlers, hooks, route permissions, guards, and exact REST URL pattern.',
2199
- ].join(' '),
2200
- {
2201
- path: z.string().optional().describe('Route path, e.g. /enfyra_user'),
2202
- routeId: z.union([z.string(), z.number()]).optional().describe('enfyra_route id. Use either path or routeId.'),
2203
- },
2204
- async ({ path, routeId }) => {
2205
- if (!path && !routeId) throw new Error('Provide path or routeId');
2206
- const state = await collectRestDefinitionState();
2207
- const route = state.routes.find((item) => (
2208
- routeId ? sameId(getId(item), routeId) : item.path === normalizeRestPath(path)
2209
- ));
2210
- if (!route) throw new Error(`Route not found: ${routeId || path}`);
2211
- const table = state.tables.find((item) => sameId(getId(item), refId(route.mainTable))) || null;
2212
-
2213
- const payload = {
2214
- apiBase: ENFYRA_API_URL.replace(/\/$/, ''),
2215
- route: enrichRoute(route, state),
2216
- mainTable: summarizeTable(table),
2217
- restPattern: {
2218
- listOrCreate: `${ENFYRA_API_URL.replace(/\/$/, '')}${route.path}`,
2219
- updateOrDelete: `${ENFYRA_API_URL.replace(/\/$/, '')}${route.path}/<id>`,
2220
- oneById: `Use GET ${route.path}?filter=${JSON.stringify({ [getPrimaryColumn(table)?.name || 'id']: { _eq: '<id>' } })}&limit=1`,
2221
- },
2222
- };
2223
-
2224
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
2225
- },
2226
- );
2227
-
2228
- server.tool(
2229
- 'inspect_feature',
2230
- [
2231
- 'Search live REST/system metadata for a feature name, route path, table, handler, hook, guard, or permission.',
2232
- 'Use when the user mentions a capability and you need to find where it lives before editing. Keep the query specific; broad searches return bounded summaries.',
2233
- ].join(' '),
2234
- {
2235
- query: z.string().describe('Feature keyword, table name, route path, handler text, hook name, or guard name'),
2236
- limit: z.number().int().positive().max(25).optional().default(8).describe('Maximum matches returned per section. Default 8 to keep output small.'),
2237
- },
2238
- async ({ query, limit }) => {
2239
- const rawQuery = String(query || '').trim();
2240
- if (rawQuery.length < 2) {
2241
- throw new Error('inspect_feature query must be at least 2 characters. Use a table name, route path, event name, or specific feature keyword.');
2242
- }
2243
- const max = Math.max(1, Math.min(Number(limit || 8), 25));
2244
- const state = await collectFeatureSearchState();
2245
- const q = rawQuery.toLowerCase();
2246
- const matchesText = (value) => JSON.stringify(value ?? '').toLowerCase().includes(q);
2247
- const tableMatches = state.tables.filter((table) => matchesText({
2248
- name: table.name,
2249
- alias: table.alias,
2250
- description: table.description,
2251
- columns: table.columns?.map((column) => ({ name: column.name, description: column.description })),
2252
- relations: table.relations?.map((relation) => ({ propertyName: relation.propertyName, description: relation.description })),
2253
- }));
2254
- const routeMatches = state.routes.filter((route) => matchesText(route));
2255
- const handlerMatches = state.handlers.filter((handler) => matchesText(handler)).map((item) => pickCodeSummary(item, 'sourceCode'));
2256
- const preHookMatches = state.preHooks.filter((hook) => matchesText(hook)).map((item) => pickCodeSummary(item, 'code'));
2257
- const postHookMatches = state.postHooks.filter((hook) => matchesText(hook)).map((item) => pickCodeSummary(item, 'code'));
2258
- const guardMatches = state.guards.filter((guard) => matchesText(guard));
2259
- const permissionMatches = [
2260
- ...state.routePermissions.filter((permission) => matchesText(permission)).map((permission) => ({ type: 'route_permission', ...permission })),
2261
- ...state.fieldPermissions.filter((permission) => matchesText(permission)).map((permission) => ({ type: 'field_permission', ...permission })),
2262
- ];
2263
-
2264
- const payload = {
2265
- targetInstance: targetInstance(),
2266
- query: rawQuery,
2267
- limit: max,
2268
- partialErrors: state.partialErrors,
2269
- counts: {
2270
- tables: tableMatches.length,
2271
- routes: routeMatches.length,
2272
- handlers: handlerMatches.length,
2273
- preHooks: preHookMatches.length,
2274
- postHooks: postHookMatches.length,
2275
- guards: guardMatches.length,
2276
- permissions: permissionMatches.length,
2277
- },
2278
- tables: tableMatches.slice(0, max).map(summarizeTable),
2279
- routes: routeMatches.slice(0, max).map((route) => enrichRoute(route, state)),
2280
- handlers: handlerMatches.slice(0, max),
2281
- preHooks: preHookMatches.slice(0, max),
2282
- postHooks: postHookMatches.slice(0, max),
2283
- guards: guardMatches.slice(0, max),
2284
- permissions: permissionMatches.slice(0, max),
2285
- detailHint: 'For a specific match, call inspect_table, inspect_route, trace_metadata_usage, or get_script_source instead of broadening this search.',
2286
- };
2287
-
2288
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
2289
- },
2290
- );
2291
-
2292
- server.tool(
2293
- 'trace_metadata_usage',
2294
- [
2295
- 'Trace where a table, route path, keyword, or script fragment appears across live metadata and script-backed records.',
2296
- 'Use this before changing production flows/handlers/hooks to find all callers or writers for a table such as cloud_provisioning_history.',
2297
- ].join(' '),
2298
- {
2299
- query: z.string().describe('Table name, route path, field name, event name, or source-code keyword to trace'),
2300
- includeSourcePreview: z.boolean().optional().default(true).describe('Include short source previews around matches.'),
2301
- limit: z.number().optional().default(25).describe('Maximum matches per section.'),
2302
- },
2303
- async ({ query, includeSourcePreview, limit }) => {
2304
- const q = String(query || '').trim();
2305
- if (!q) throw new Error('query is required.');
2306
- const lower = q.toLowerCase();
2307
- const max = Math.max(1, Math.min(Number(limit || 25), 100));
2308
- const state = await collectRestDefinitionState();
2309
- const contains = (value) => JSON.stringify(value ?? '').toLowerCase().includes(lower);
2310
- const sourceContains = (record) => getRecordSource(record).sourceCode.toLowerCase().includes(lower);
2311
-
2312
- const scriptTableResults = await Promise.all(SCRIPT_BACKED_TABLES.map(async (tableName) => {
2313
- const fields = scriptTraceFields(tableName);
2314
- let result = await fetchAPI(ENFYRA_API_URL, `/${tableName}?limit=1000&fields=${encodeURIComponent(fields)}`).catch((error) => ({ error }));
2315
- if (result?.error && fields !== '*') {
2316
- result = await fetchAPI(ENFYRA_API_URL, `/${tableName}?limit=1000&fields=*`).catch((error) => ({ error }));
2317
- }
2318
- return { tableName, records: unwrapData(result), error: result?.error?.message || null };
2319
- }));
2320
- const scriptMatches = [];
2321
- const scriptErrors = [];
2322
- for (const { tableName, records, error } of scriptTableResults) {
2323
- if (error) {
2324
- scriptErrors.push({ tableName, error });
2325
- continue;
2326
- }
2327
- for (const record of records) {
2328
- const { field, sourceCode } = getRecordSource(record);
2329
- if (!field || !sourceContains(record)) continue;
2330
- scriptMatches.push({
2331
- ...scriptRecordLabel(tableName, record),
2332
- sourceField: field,
2333
- sourceLength: sourceCode.length,
2334
- sourceSha256: sha256(sourceCode),
2335
- preview: includeSourcePreview ? sourcePreview(sourceCode, q) : undefined,
2336
- });
2337
- }
2338
- }
2339
-
2340
- const tableMatches = state.tables.filter((table) => contains({
2341
- name: table.name,
2342
- alias: table.alias,
2343
- description: table.description,
2344
- columns: (table.columns || []).map((column) => ({ name: column.name, type: column.type, description: column.description })),
2345
- relations: (table.relations || []).map((relation) => ({ propertyName: relation.propertyName, type: relation.type, description: relation.description })),
2346
- }));
2347
- const routeMatches = state.routes.filter((route) => contains({
2348
- path: route.path,
2349
- mainTable: route.mainTable,
2350
- description: route.description,
2351
- }));
2352
- const fieldPermissionMatches = state.fieldPermissions.filter((permission) => contains(permission));
2353
- const guardMatches = state.guards.filter((guard) => contains(guard));
2354
- const routePermissionMatches = state.routePermissions.filter((permission) => contains(permission));
2355
-
2356
- return { content: [{ type: 'text', text: JSON.stringify({
2357
- query: q,
2358
- counts: {
2359
- tables: tableMatches.length,
2360
- routes: routeMatches.length,
2361
- scripts: scriptMatches.length,
2362
- fieldPermissions: fieldPermissionMatches.length,
2363
- routePermissions: routePermissionMatches.length,
2364
- guards: guardMatches.length,
2365
- },
2366
- tables: tableMatches.map(summarizeTable).slice(0, max),
2367
- routes: routeMatches.map((route) => enrichRoute(route, state)).slice(0, max),
2368
- scripts: scriptMatches.slice(0, max),
2369
- fieldPermissions: fieldPermissionMatches.slice(0, max),
2370
- routePermissions: routePermissionMatches.slice(0, max),
2371
- guards: guardMatches.slice(0, max),
2372
- scriptReadErrors: scriptErrors,
2373
- next: 'Use inspect_route/inspect_table for structure, get_script_source for full source, and patch_script_source for exact validated edits.',
2374
- }, null, 2) }] };
2375
- },
2376
- );
2377
-
2378
- server.tool(
2379
- 'test_rest_endpoint',
2380
- [
2381
- 'Execute a real REST request against the configured Enfyra API base.',
2382
- 'Use this after inspecting a route or changing handlers/hooks/guards. Pass paths like /enfyra_table?limit=1, not external URLs.',
2383
- 'Do not use this for admin app page/menu routes such as /cloud/projects/:id unless inspect_route confirms an API route with that exact path.',
2384
- ].join(' '),
2385
- {
2386
- method: z.string().optional().default('GET').describe('HTTP method name. Must exist in enfyra_method.name for Enfyra route-backed calls.'),
2387
- path: z.string().describe('Enfyra API path, e.g. /enfyra_route?limit=1'),
2388
- query: z.string().optional().describe('Optional query params JSON object, merged onto path query string'),
2389
- body: z.string().optional().describe('Optional JSON request body string'),
2390
- headers: z.string().optional().describe('Optional headers JSON object'),
2391
- useAuth: z.boolean().optional().default(true).describe('Attach MCP admin Bearer token. Set false to test public access.'),
2392
- },
2393
- async ({ method, path, query, body, headers, useAuth }) => {
2394
- const httpMethod = normalizeMethodNameInput(method || 'GET');
2395
- const restPath = normalizeRestPath(path);
2396
- const url = new URL(`${ENFYRA_API_URL.replace(/\/$/, '')}${restPath}`);
2397
- const queryObj = parseJsonArg(query, {});
2398
- for (const [key, value] of Object.entries(queryObj || {})) {
2399
- url.searchParams.set(key, typeof value === 'string' ? value : JSON.stringify(value));
2400
- }
2401
-
2402
- const requestHeaders = {
2403
- 'Content-Type': 'application/json',
2404
- ...(parseJsonArg(headers, {}) || {}),
2405
- };
2406
- if (useAuth) {
2407
- requestHeaders.Authorization = `Bearer ${await getValidToken()}`;
2408
- }
2409
-
2410
- const started = Date.now();
2411
- const response = await fetch(url, {
2412
- method: httpMethod,
2413
- headers: requestHeaders,
2414
- ...(body !== undefined && body !== null && httpMethod !== 'GET' ? { body } : {}),
2415
- });
2416
- const contentType = response.headers.get('content-type') || '';
2417
- const responseText = await response.text();
2418
- let parsedBody = responseText;
2419
- if (contentType.includes('application/json') && responseText) {
2420
- parsedBody = JSON.parse(responseText);
2421
- }
2422
-
2423
- const payload = {
2424
- request: {
2425
- method: httpMethod,
2426
- url: url.toString(),
2427
- authenticated: !!useAuth,
2428
- },
2429
- response: {
2430
- ok: response.ok,
2431
- status: response.status,
2432
- statusText: response.statusText,
2433
- contentType,
2434
- durationMs: Date.now() - started,
2435
- body: parsedBody,
2436
- },
2437
- };
2438
-
2439
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
2440
- },
2441
- );
2442
-
2443
- server.tool('get_all_routes', 'List route definitions with minimal fields. Every call must pass either limit or all=true. Call inspect_route for handlers/hooks/permissions detail.', {
2444
- includeDisabled: z.boolean().optional().default(false).describe('Include disabled routes'),
2445
- search: z.string().optional().describe('Optional path or table substring filter. Use this before creating a route to check duplicates.'),
2446
- limit: z.number().int().positive().optional().describe('Maximum routes returned after search. Required unless all=true. Do not invent arbitrary limits for "all"; use all=true instead.'),
2447
- all: z.boolean().optional().default(false).describe('Return all matched routes. Use this when the user asks for all routes or a complete route list.'),
2448
- }, async ({ includeDisabled, search, limit, all }) => {
2449
- if (!all && limit === undefined) {
2450
- throw new Error('get_all_routes requires either limit or all=true. Do not rely on implicit default page sizes.');
2451
- }
2452
- if (all && limit !== undefined) {
2453
- throw new Error('get_all_routes accepts either all=true or limit, not both.');
2454
- }
2455
- const filter = includeDisabled ? {} : { isEnabled: { _eq: true } };
2456
- const queryParams = new URLSearchParams({
2457
- filter: JSON.stringify(filter),
2458
- fields: 'id,path,mainTable.name,availableMethods.*,publicMethods.*,isEnabled',
2459
- limit: all ? '0' : '1000',
2460
- });
2461
- const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_route?${queryParams.toString()}`);
2462
- const q = search ? search.toLowerCase() : null;
2463
- const allRoutes = summarizeRoutes(result);
2464
- const matchedRoutes = q
2465
- ? allRoutes.filter((route) => JSON.stringify({
2466
- path: route.path,
2467
- mainTable: route.mainTable,
2468
- }).toLowerCase().includes(q))
2469
- : allRoutes;
2470
- const routeLimit = all ? matchedRoutes.length : limit;
2471
- const payload = {
2472
- statusCode: result?.statusCode,
2473
- success: result?.success,
2474
- totalRouteCount: allRoutes.length,
2475
- matchedRouteCount: matchedRoutes.length,
2476
- returnedRouteCount: Math.min(matchedRoutes.length, routeLimit),
2477
- all: !!all,
2478
- complete: all || routeLimit >= matchedRoutes.length,
2479
- hardCap: all ? null : routeLimit,
2480
- search: search || null,
2481
- routes: matchedRoutes.slice(0, routeLimit),
2482
- detailHint: matchedRoutes.length > routeLimit
2483
- ? `Response truncated to ${routeLimit} routes. Re-run with search or a higher limit, then inspect_route({ path }) for details.`
2484
- : 'Use inspect_route({ path }) or inspect_route({ routeId }) for handlers, hooks, permissions, and guards.',
2485
- };
2486
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
2487
- });
2488
-
2489
- server.tool(
2490
- 'create_route',
2491
- [
2492
- '**Use this when the user wants a new REST API route or path** — not `create_table`. Custom routes must omit `mainTableId`.',
2493
- '`mainTableId` is only a marker for canonical table routes such as `/orders`; do not set it for `/orders/stats`, `/reports/summary`, `/auth/login`, or any custom path.',
2494
- 'Do NOT create a new enfyra_table only to expose an endpoint; create a route without `mainTableId`, then have the handler/hook query explicit repos such as `$ctx.$repos.orders`.',
2495
- 'availableMethods = which REST verbs the route responds to. publicMethods = which REST verbs are public (no auth). GraphQL is enabled separately through enfyra_graphql/update_table graphqlEnabled.',
2496
- 'After creation the tool auto-reloads routes. Then create handlers for specific methods via create_handler on this route id.',
2497
- 'Flow: create_route → create_handler (per method) → optionally create_pre_hook / create_post_hook → test via HTTP or admin test APIs (see server instructions).',
2498
- ].join(' '),
2499
- {
2500
- path: z.string().describe('URL path, must start with / (e.g., "/my-endpoint")'),
2501
- mainTableId: z.union([z.string(), z.number()]).optional().describe('Only set for the canonical table route `/<table_name>`. Omit for every custom route.'),
2502
- methods: z.array(z.string())
2503
- .describe('HTTP method names this route supports (availableMethods). Each value must exist in enfyra_method.name. Common: ["GET","POST","PATCH","DELETE"].'),
2504
- publicMethods: z.array(z.string()).optional()
2505
- .describe('Methods accessible WITHOUT auth token. Omit = all methods require auth.'),
2506
- isEnabled: z.boolean().optional().default(true).describe('Enable route immediately'),
2507
- description: z.string().optional().describe('Route description'),
2508
- globalRulesAckKey: globalRulesAckParam(z),
2509
- },
2510
- async ({ path: routePath, mainTableId, methods, publicMethods, isEnabled, description, globalRulesAckKey }) => {
2511
- assertGlobalRulesAck(globalRulesAckKey);
2512
- const methodMap = await getMethodMap();
2513
- const normalizedPath = normalizeRestPath(routePath);
2514
-
2515
- const body = {
2516
- path: normalizedPath,
2517
- isEnabled,
2518
- description,
2519
- availableMethods: resolveMethodIds(methodMap, methods),
2520
- };
2521
-
2522
- if (mainTableId !== undefined && mainTableId !== null) {
2523
- const { tables } = await getMetadataTables();
2524
- validateMainTableRoutePath(tables, mainTableId, normalizedPath);
2525
- body.mainTable = { id: mainTableId };
2526
- }
2527
-
2528
- if (publicMethods && publicMethods.length > 0) {
2529
- body.publicMethods = resolveMethodIds(methodMap, publicMethods);
2530
- }
2531
-
2532
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_route', {
2533
- method: 'POST',
2534
- body: JSON.stringify(body),
2535
- });
2536
-
2537
- const routeReload = await reloadRoutesResult();
2538
-
2539
- const created = firstDataRecord(result);
2540
- return { content: [{ type: 'text', text: JSON.stringify({
2541
- action: 'created',
2542
- route: {
2543
- id: getId(created),
2544
- path: created?.path,
2545
- mainTableId: mainTableId ?? null,
2546
- availableMethods: methods,
2547
- publicMethods: publicMethods || [],
2548
- },
2549
- routeReload,
2550
- next: `Use create_handler({ routeId: ${JSON.stringify(getId(created))}, method: "GET", sourceCode }) for custom code. Create extra enfyra_method.name rows first for custom methods such as PUT.`,
2551
- }, null, 2) }] };
2552
- },
2553
- );
2554
-
2555
- server.tool(
2556
- 'create_handler',
2557
- [
2558
- 'Create a handler for a route+method. One handler per (route, method) pair.',
2559
- 'Attach to the route the user cares about (`get_all_routes`): typically a path from `create_route`, not a spurious table created only for handlers.',
2560
- 'Use sourceCode, not logic/name. Enfyra compiles sourceCode into compiledCode; do not send compiledCode.',
2561
- 'Handler code runs inside a sandbox with $ctx. Use macros: @BODY, @QUERY, @PARAMS, @USER, @REPOS, @HELPERS, @THROW400..@THROW503, @SOCKET, @PKGS, @LOGS, @SHARE.',
2562
- 'Or use $ctx directly: $ctx.$body, $ctx.$repos.main.find(), $ctx.$helpers.$bcrypt.hash(), etc.',
2563
- 'require("pkg") works for installed Server packages. console.log() writes to $share.$logs.',
2564
- ].join(' '),
2565
- {
2566
- routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
2567
- method: z.string().optional()
2568
- .describe('Single enfyra_method.name to create. Prefer this for one handler.'),
2569
- methods: z.array(z.string()).optional()
2570
- .describe('Batch create multiple handlers. Use only when the same sourceCode applies to every method.'),
2571
- sourceCode: z.string().describe('Handler JavaScript sourceCode. Do not use logic; backend CRUD rejects logic.'),
2572
- scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for compiler. Default javascript.'),
2573
- timeout: z.number().optional().describe('Timeout in ms (default: system DEFAULT_HANDLER_TIMEOUT, usually 30000)'),
2574
- globalRulesAckKey: globalRulesAckParam(z),
2575
- knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
2576
- },
2577
- async ({ routeId, method, methods, sourceCode, scriptLanguage, timeout, globalRulesAckKey, knowledgeAckKey }) => {
2578
- assertGlobalRulesAck(globalRulesAckKey);
2579
- assertDynamicCodeKnowledgeAck(knowledgeAckKey);
2580
- const methodNames = methods && methods.length > 0 ? methods : method ? [method] : [];
2581
- if (methodNames.length === 0) throw new Error('Provide method or methods');
2582
- const methodMap = await getMethodMap();
2583
- const results = [];
2584
- const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'enfyra_route_handler', {
2585
- sourceCode,
2586
- scriptLanguage,
2587
- });
2588
-
2589
- for (const methodName of methodNames) {
2590
- const methodId = methodMap[methodName.toUpperCase()];
2591
- if (!methodId) throw new Error(`Unknown method: ${methodName}. Valid: ${Object.keys(methodMap).join(', ')}`);
2592
-
2593
- const body = { route: { id: routeId }, method: { id: methodId }, sourceCode, scriptLanguage };
2594
- if (timeout) body.timeout = timeout;
2595
-
2596
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_route_handler', {
2597
- method: 'POST',
2598
- body: JSON.stringify(body),
2599
- });
2600
- const created = firstDataRecord(result);
2601
- results.push({
2602
- id: getId(created),
2603
- routeId,
2604
- method: methodName,
2605
- scriptLanguage,
2606
- timeout: created?.timeout ?? timeout ?? null,
2607
- });
2608
- }
2609
-
2610
- const routeReload = await reloadRoutesResult();
2611
-
2612
- return { content: [{ type: 'text', text: JSON.stringify({
2613
- action: 'created',
2614
- handlers: results,
2615
- scriptValidation,
2616
- routeReload,
2617
- detailHint: 'Use inspect_route with the same routeId/path to inspect saved handlers.',
2618
- }, null, 2) }] };
2619
- },
2620
- );
2621
-
2622
- server.tool(
2623
- 'create_pre_hook',
2624
- [
2625
- 'Create a pre-hook that runs BEFORE the handler. Use to validate, transform, or inject data.',
2626
- 'Use `routeId` from `create_route` or `get_all_routes` — do not create a new table just to get a route id.',
2627
- 'Macros: @BODY, @QUERY, @PARAMS, @USER, @REPOS, @HELPERS, @THROW400..@THROW503.',
2628
- 'If the hook returns a value, that value becomes the response (handler is skipped).',
2629
- ].join(' '),
2630
- {
2631
- routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
2632
- name: z.string().describe('Hook name (unique per route)'),
2633
- code: z.string().describe('Hook JavaScript sourceCode. MCP stores it as sourceCode and lets Enfyra compile compiledCode.'),
2634
- scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for compiler. Default javascript.'),
2635
- methods: z.array(z.string()).optional()
2636
- .describe('Method names this hook applies to. Default: built-in REST methods GET, POST, PATCH, DELETE.'),
2637
- priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
2638
- isEnabled: z.boolean().optional().default(true).describe('Enable hook immediately'),
2639
- globalRulesAckKey: globalRulesAckParam(z),
2640
- knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
2641
- },
2642
- async ({ routeId, name, code, scriptLanguage, methods, priority, isEnabled, globalRulesAckKey, knowledgeAckKey }) => {
2643
- assertGlobalRulesAck(globalRulesAckKey);
2644
- assertDynamicCodeKnowledgeAck(knowledgeAckKey);
2645
- const methodMap = await getMethodMap();
2646
- const methodNames = methods || ['GET', 'POST', 'PATCH', 'DELETE'];
2647
- const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'enfyra_pre_hook', {
2648
- sourceCode: code,
2649
- scriptLanguage,
2650
- });
2651
-
2652
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_pre_hook', {
2653
- method: 'POST',
2654
- body: JSON.stringify({
2655
- route: { id: routeId },
2656
- name,
2657
- sourceCode: code,
2658
- scriptLanguage,
2659
- methods: resolveMethodIds(methodMap, methodNames),
2660
- priority,
2661
- isEnabled,
2662
- }),
2663
- });
2664
-
2665
- const routeReload = await reloadRoutesResult();
2666
-
2667
- const created = firstDataRecord(result);
2668
- return { content: [{ type: 'text', text: JSON.stringify({
2669
- action: 'created',
2670
- kind: 'pre_hook',
2671
- id: getId(created),
2672
- name,
2673
- routeId,
2674
- scriptValidation,
2675
- routeReload,
2676
- }, null, 2) }] };
2677
- },
2678
- );
2679
-
2680
- server.tool(
2681
- 'create_post_hook',
2682
- [
2683
- 'Create a post-hook that runs AFTER the handler. Use to transform responses or add metadata.',
2684
- 'Use `routeId` from `create_route` or `get_all_routes` — do not create a new table just to get a route id.',
2685
- 'Macros: @DATA, @STATUS, @ERROR, @BODY, @QUERY, @USER, @SHARE, @API (post-hooks always run; on error path @ERROR is set, @DATA is null).',
2686
- 'Mutate @DATA / $ctx.$data in place, or return a value: if the hook returns anything other than undefined, that value replaces $ctx.$data as the response payload.',
2687
- ].join(' '),
2688
- {
2689
- routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
2690
- name: z.string().describe('Hook name (unique per route)'),
2691
- code: z.string().describe('Hook JavaScript sourceCode. MCP stores it as sourceCode and lets Enfyra compile compiledCode.'),
2692
- scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for compiler. Default javascript.'),
2693
- methods: z.array(z.string()).optional()
2694
- .describe('Method names this hook applies to. Default: built-in REST methods GET, POST, PATCH, DELETE.'),
2695
- priority: z.number().optional().default(0).describe('Execution order (lower = first)'),
2696
- isEnabled: z.boolean().optional().default(true).describe('Enable hook immediately'),
2697
- globalRulesAckKey: globalRulesAckParam(z),
2698
- knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
2699
- },
2700
- async ({ routeId, name, code, scriptLanguage, methods, priority, isEnabled, globalRulesAckKey, knowledgeAckKey }) => {
2701
- assertGlobalRulesAck(globalRulesAckKey);
2702
- assertDynamicCodeKnowledgeAck(knowledgeAckKey);
2703
- const methodMap = await getMethodMap();
2704
- const methodNames = methods || ['GET', 'POST', 'PATCH', 'DELETE'];
2705
- const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'enfyra_post_hook', {
2706
- sourceCode: code,
2707
- scriptLanguage,
2708
- });
2709
-
2710
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_post_hook', {
2711
- method: 'POST',
2712
- body: JSON.stringify({
2713
- route: { id: routeId },
2714
- name,
2715
- sourceCode: code,
2716
- scriptLanguage,
2717
- methods: resolveMethodIds(methodMap, methodNames),
2718
- priority,
2719
- isEnabled,
2720
- }),
2721
- });
2722
-
2723
- const routeReload = await reloadRoutesResult();
2724
-
2725
- const created = firstDataRecord(result);
2726
- return { content: [{ type: 'text', text: JSON.stringify({
2727
- action: 'created',
2728
- kind: 'post_hook',
2729
- id: getId(created),
2730
- name,
2731
- routeId,
2732
- scriptValidation,
2733
- routeReload,
2734
- }, null, 2) }] };
2735
- },
2736
- );
2737
-
2738
- server.tool(
2739
- 'audit_route_access',
2740
- [
2741
- 'Audit route access for one or more routes.',
2742
- 'Use this before granting access or debugging 403s. It reports available methods, public methods, skipRoleGuard methods, route permissions, and optional missing methods for one role/user scope.',
2743
- ].join(' '),
2744
- {
2745
- path: z.string().optional().describe('Exact route path, e.g. /orders'),
2746
- routeId: z.union([z.string(), z.number()]).optional().describe('Exact route id'),
2747
- search: z.string().optional().describe('Optional route path search when path/routeId is not provided'),
2748
- roleId: z.union([z.string(), z.number()]).optional().describe('Expected role id to check'),
2749
- roleName: z.string().optional().describe('Expected role name to resolve, e.g. user'),
2750
- allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Expected direct/specific user ids to check'),
2751
- methods: z.array(z.string()).optional().describe('Methods expected to be allowed for this scope'),
2752
- limit: z.number().int().positive().max(100).optional().default(25).describe('Maximum routes returned for search mode'),
2753
- },
2754
- async ({ path, routeId, search, roleId, roleName, allowedUserIds, methods, limit }) => {
2755
- if ([path, routeId, search].filter((value) => value !== undefined && value !== null && value !== '').length > 1) {
2756
- throw new Error('Use only one of path, routeId, or search.');
2757
- }
2758
- if (roleId && roleName) throw new Error('Provide roleId or roleName, not both.');
2759
-
2760
- const [routes, routePermissions, roles, methodIdNameMap] = await Promise.all([
2761
- fetchAll('/enfyra_route?limit=1000'),
2762
- fetchAll('/enfyra_route_permission?limit=1000'),
2763
- fetchAll('/enfyra_role?limit=1000'),
2764
- getMethodIdNameMap(),
2765
- ]);
2766
-
2767
- const role = resolveRoleByNameOrId(roles, { roleId, roleName });
2768
- const normalizedPath = path ? normalizeRestPath(path) : null;
2769
- const query = search ? String(search).toLowerCase() : null;
2770
- const matchedRoutes = routes.filter((route) => {
2771
- if (routeId) return sameId(getId(route), routeId);
2772
- if (normalizedPath) return route.path === normalizedPath;
2773
- if (query) return String(route.path || '').toLowerCase().includes(query);
2774
- return true;
2775
- }).slice(0, limit);
2776
-
2777
- const expectedMethods = normalizeMethodNames(methods || []);
2778
- const payload = {
2779
- guidance: {
2780
- publicAccess: 'publicMethods bypass RoleGuard and do not require enfyra_route_permission.',
2781
- authenticatedAccess: 'For non-public methods, Enfyra admin UI PermissionGate and backend RoleGuard both expect enabled enfyra_route_permission rows with matching route + HTTP method.',
2782
- directUserAccess: 'allowedRoutePermissions on /me represent direct user-scoped route permissions; role.routePermissions represent role-scoped permissions.',
2783
- },
2784
- expectedScope: {
2785
- role: role ? { id: getId(role), name: role.name } : null,
2786
- allowedUserIds: allowedUserIds || [],
2787
- methods: expectedMethods,
2788
- },
2789
- returnedRouteCount: matchedRoutes.length,
2790
- routes: matchedRoutes.map((route) => summarizeRouteAccess(route, routePermissions, methodIdNameMap, {
2791
- roleId: role ? getId(role) : roleId,
2792
- roleRequired: !!(role || roleId || roleName),
2793
- allowedUserIds,
2794
- methods: expectedMethods,
2795
- })),
2796
- };
2797
-
2798
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
2799
- },
2800
- );
2801
-
2802
- server.tool(
2803
- 'ensure_route_access',
2804
- [
2805
- 'Create or update authenticated route access for one role/user scope.',
2806
- 'Use this instead of raw enfyra_route_permission CRUD when fixing 403s. It resolves roleName/route/method ids, validates route.availableMethods, merges existing permission methods by default, and reloads routes.',
2807
- ].join(' '),
2808
- {
2809
- path: z.string().optional().describe('Route path, e.g. /orders'),
2810
- routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
2811
- methods: z.array(z.string()).describe('HTTP method names to allow, e.g. ["GET", "POST"].'),
2812
- roleId: z.union([z.string(), z.number()]).optional().describe('Role id scope'),
2813
- roleName: z.string().optional().describe('Role name scope, e.g. user. Prefer this when an LLM does not know role ids.'),
2814
- allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Specific user ids scope. Omit for role-wide access.'),
2815
- mode: z.enum(['merge', 'replace']).optional().default('merge').describe('merge adds methods to an existing permission; replace overwrites methods on the matched permission.'),
2816
- description: z.string().optional().describe('Admin note'),
2817
- isEnabled: z.boolean().optional().default(true).describe('Enable the permission'),
2818
- globalRulesAckKey: globalRulesAckParam(z),
2819
- },
2820
- async ({ path, routeId, methods, roleId, roleName, allowedUserIds, mode, description, isEnabled, globalRulesAckKey }) => {
2821
- assertGlobalRulesAck(globalRulesAckKey);
2822
- if (!path && !routeId) throw new Error('Provide path or routeId.');
2823
- if (path && routeId) throw new Error('Provide path or routeId, not both.');
2824
- if (roleId && roleName) throw new Error('Provide roleId or roleName, not both.');
2825
- if (!roleId && !roleName && (!allowedUserIds || allowedUserIds.length === 0)) {
2826
- throw new Error('Provide roleId, roleName, or allowedUserIds.');
2827
- }
2828
-
2829
- const [routes, routePermissions, roles, methodMap, methodIdNameMap] = await Promise.all([
2830
- fetchAll('/enfyra_route?limit=1000'),
2831
- fetchAll('/enfyra_route_permission?limit=1000'),
2832
- fetchAll('/enfyra_role?limit=1000'),
2833
- getMethodMap(),
2834
- getMethodIdNameMap(),
2835
- ]);
2836
- const route = routes.find((item) => (
2837
- routeId ? sameId(getId(item), routeId) : item.path === normalizeRestPath(path)
2838
- ));
2839
- if (!route) throw new Error(`Route not found: ${routeId || path}`);
2840
-
2841
- const role = resolveRoleByNameOrId(roles, { roleId, roleName });
2842
- const scope = {
2843
- roleId: role ? getId(role) : roleId,
2844
- allowedUserIds: allowedUserIds || [],
2845
- };
2846
- const requestedMethods = validateMethodsForRoute(route, methods, methodMap, methodIdNameMap);
2847
- const existing = findRoutePermission(routePermissions, getId(route), scope);
2848
- const existingMethods = existing ? summarizeRoutePermission(existing, methodIdNameMap).methods : [];
2849
- const finalMethods = mergeMethodNames(existingMethods, requestedMethods, mode);
2850
- const methodRefs = resolveMethodIds(methodMap, finalMethods);
2851
- const publicMethods = routePublicMethodNames(route, methodIdNameMap);
2852
- const alreadyPublic = requestedMethods.filter((method) => publicMethods.includes(method));
2853
-
2854
- let result;
2855
- let action;
2856
- if (existing) {
2857
- action = 'updated';
2858
- const patchBody = {
2859
- isEnabled,
2860
- methods: methodRefs,
2861
- ...(description !== undefined ? { description } : {}),
2862
- };
2863
- result = await fetchAPI(ENFYRA_API_URL, `/enfyra_route_permission/${encodeURIComponent(String(getId(existing)))}`, {
2864
- method: 'PATCH',
2865
- body: JSON.stringify(patchBody),
2866
- });
2867
- } else {
2868
- action = 'created';
2869
- const createBody = {
2870
- isEnabled,
2871
- description,
2872
- route: { id: getId(route) },
2873
- methods: methodRefs,
2874
- ...(scope.roleId ? { role: { id: scope.roleId } } : {}),
2875
- ...(scope.allowedUserIds.length ? { allowedUsers: scope.allowedUserIds.map((id) => ({ id })) } : {}),
2876
- };
2877
- result = await fetchAPI(ENFYRA_API_URL, '/enfyra_route_permission', {
2878
- method: 'POST',
2879
- body: JSON.stringify(createBody),
2880
- });
2881
- }
2882
-
2883
- const routeReload = await reloadRoutesResult();
2884
- const saved = firstDataRecord(result);
2885
- const payload = {
2886
- action,
2887
- kind: 'route_access',
2888
- route: {
2889
- id: getId(route),
2890
- path: route.path,
2891
- availableMethods: routeAvailableMethodNames(route, methodIdNameMap),
2892
- publicMethods,
2893
- },
2894
- scope: {
2895
- role: role ? { id: getId(role), name: role.name } : null,
2896
- allowedUserIds: scope.allowedUserIds,
2897
- },
2898
- permission: {
2899
- id: getId(saved) || getId(existing),
2900
- methods: finalMethods,
2901
- alreadyPublic,
2902
- isEnabled,
2903
- },
2904
- result,
2905
- routeReload,
2906
- auditHint: `Call audit_route_access({ path: "${route.path}", ${role ? `roleName: "${role.name}", ` : ''}methods: ${JSON.stringify(requestedMethods)} }) to verify.`,
2907
- };
2908
-
2909
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
2910
- },
2911
- );
2912
-
2913
- // Register table tools
2914
- registerTableTools(server, ENFYRA_API_URL);
2915
- registerPlatformOperationTools(server, ENFYRA_API_URL);
2916
-
2917
- // ============================================================================
2918
- // CACHE & SYSTEM TOOLS
2919
- // ============================================================================
2920
-
2921
- server.tool('reload_all', 'Reload all caches (metadata, routes, GraphQL)', {
2922
- globalRulesAckKey: globalRulesAckParam(z),
2923
- }, async ({ globalRulesAckKey }) => {
2924
- assertGlobalRulesAck(globalRulesAckKey);
2925
- const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload', { method: 'POST' });
2926
- return jsonContent({ action: 'reloaded_all', result });
2927
- });
2928
-
2929
- server.tool('reload_metadata', 'Reload metadata cache only', {
2930
- globalRulesAckKey: globalRulesAckParam(z),
2931
- }, async ({ globalRulesAckKey }) => {
2932
- assertGlobalRulesAck(globalRulesAckKey);
2933
- const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/metadata', { method: 'POST' });
2934
- return jsonContent({ action: 'reloaded_metadata', result });
2935
- });
2936
-
2937
- server.tool('reload_routes', 'Reload routes cache only', {
2938
- globalRulesAckKey: globalRulesAckParam(z),
2939
- }, async ({ globalRulesAckKey }) => {
2940
- assertGlobalRulesAck(globalRulesAckKey);
2941
- const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' });
2942
- return jsonContent({ action: 'reloaded_routes', result });
2943
- });
2944
-
2945
- server.tool('reload_graphql', 'Reload GraphQL schema', {
2946
- globalRulesAckKey: globalRulesAckParam(z),
2947
- }, async ({ globalRulesAckKey }) => {
2948
- assertGlobalRulesAck(globalRulesAckKey);
2949
- const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/graphql', { method: 'POST' });
2950
- return jsonContent({ action: 'reloaded_graphql', result });
2951
- });
2952
-
2953
- // ============================================================================
2954
- // LOGS TOOLS
2955
- // ============================================================================
2956
-
2957
- server.tool('get_log_files', 'List available log files and stats', {}, async () => {
2958
- const result = await fetchAPI(ENFYRA_API_URL, '/logs');
2959
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
2960
- });
2961
-
2962
- server.tool('get_log_content', 'Get content of a specific log file', {
2963
- filename: z.string().describe('Log file name'),
2964
- page: z.number().optional().default(1).describe('Page number'),
2965
- pageSize: z.number().optional().default(100).describe('Lines per page'),
2966
- filter: z.string().optional().describe('Text filter'),
2967
- level: z.string().optional().describe('Log level filter (INFO, WARN, ERROR)'),
2968
- }, async ({ filename, page, pageSize, filter, level }) => {
2969
- const queryParams = new URLSearchParams();
2970
- if (page) queryParams.set('page', String(page));
2971
- if (pageSize) queryParams.set('pageSize', String(pageSize));
2972
- if (filter) queryParams.set('filter', filter);
2973
- if (level) queryParams.set('level', level);
2974
- const result = await fetchAPI(ENFYRA_API_URL, `/logs/${filename}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`);
2975
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
2976
- });
2977
-
2978
- server.tool('tail_log', 'Get last N lines from a log file', {
2979
- filename: z.string().describe('Log file name'),
2980
- lines: z.number().optional().default(50).describe('Number of lines to retrieve'),
2981
- }, async ({ filename, lines }) => {
2982
- const result = await fetchAPI(ENFYRA_API_URL, `/logs/${filename}/tail?lines=${lines}`);
2983
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
2984
- });
2985
-
2986
- server.tool('search_logs', 'Search for ERROR or WARN logs across recent log files', {
2987
- level: z.enum(['ERROR', 'WARN', 'INFO']).optional().default('ERROR').describe('Log level'),
2988
- keyword: z.string().optional().describe('Keyword to filter logs'),
2989
- limit: z.number().optional().default(50).describe('Max results per level'),
2990
- }, async ({ level, keyword, limit }) => {
2991
- const logFilesResult = await fetchAPI(ENFYRA_API_URL, '/logs');
2992
- const logFiles = logFilesResult.files || [];
2993
- const recentFiles = logFiles.filter((file) => {
2994
- const name = file?.name || '';
2995
- return /^app[.-]/.test(name) || /^error[.-]/.test(name);
2996
- });
2997
- const results = [];
2998
- for (const file of recentFiles.slice(0, 3)) {
2999
- try {
3000
- const contentResult = await fetchAPI(ENFYRA_API_URL, `/logs/${file.name}?level=${level}&pageSize=${limit}`);
3001
- const lines = contentResult.lines || contentResult.data || [];
3002
- const filteredLines = keyword ? lines.filter(l => JSON.stringify(l).toLowerCase().includes(keyword.toLowerCase())) : lines;
3003
- if (filteredLines.length > 0) results.push({ file: file.name, level, logs: filteredLines });
3004
- } catch (e) { /* skip */ }
3005
- }
3006
- return { content: [{ type: 'text', text: `Found ${results.length} files:\n${JSON.stringify(results, null, 2)}` }] };
3007
- });
3008
-
3009
- // ============================================================================
3010
- // AUTH & USER TOOLS
3011
- // ============================================================================
3012
-
3013
- server.tool('get_current_user', 'Get current authenticated user info', {}, async () => {
3014
- const result = await fetchAPI(ENFYRA_API_URL, '/me');
3015
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
3016
- });
3017
-
3018
- server.tool(
3019
- 'get_permission_profile',
3020
- [
3021
- 'Inspect the current token permission profile using the same route-permission model as Enfyra admin UI usePermissions().',
3022
- 'Use this before debugging 403s or before relying on admin helper tools with a non-root API token.',
3023
- 'Reports which MCP tool groups need route permissions such as /admin/script/validate, /admin/test/run, /admin/flow/trigger/:id, and reload endpoints.',
3024
- ].join(' '),
3025
- {},
3026
- async () => {
3027
- const fields = DEFAULT_ME_PERMISSION_FIELDS.join(',');
3028
- const result = await fetchAPI(ENFYRA_API_URL, `/me?fields=${encodeURIComponent(fields)}`);
3029
- const user = firstDataRecord(result);
3030
- return jsonContent(summarizePermissionProfile(user));
3031
- },
3032
- );
3033
-
3034
- server.tool('get_all_roles', 'Get all role definitions', {}, async () => {
3035
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_role?limit=100');
3036
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
3037
- });
3038
-
3039
- server.tool('login', 'Force authentication to Enfyra and get a new access token', {
3040
- apiToken: z.string().optional().describe('API token for MCP and automation'),
3041
- }, async ({ apiToken }) => {
3042
- const token = apiToken || ENFYRA_API_TOKEN;
3043
- if (token) {
3044
- await exchangeApiToken(ENFYRA_API_URL, token);
3045
- const expiry = getTokenExpiry();
3046
- const expiryLabel = expiry === Infinity ? 'no expiration' : new Date(expiry).toISOString();
3047
- return { content: [{ type: 'text', text: `Authenticated with API token.\nToken expires: ${expiryLabel}` }] };
3048
- }
3049
- throw new Error('ENFYRA_API_TOKEN required');
3050
- });
3051
-
3052
- // ============================================================================
3053
- // PACKAGE TOOLS
3054
- // ============================================================================
3055
-
3056
- server.tool(
3057
- 'search_npm',
3058
- 'Search NPM registry for packages. Returns name, version, description for installation.',
3059
- {
3060
- query: z.string().describe('Package name or search term (e.g., "axios", "node-ssh", "dayjs")'),
3061
- limit: z.number().optional().default(5).describe('Max results (default: 5)'),
3062
- },
3063
- async ({ query, limit }) => {
3064
- const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=${limit}`;
3065
- const response = await fetch(url);
3066
- if (!response.ok) throw new Error(`NPM search failed: ${response.statusText}`);
3067
- const data = await response.json();
3068
-
3069
- const packages = data.objects.map((obj) => ({
3070
- name: obj.package.name,
3071
- version: obj.package.version,
3072
- description: obj.package.description || '',
3073
- }));
3074
-
3075
- return {
3076
- content: [{
3077
- type: 'text',
3078
- text: JSON.stringify({ packages, total: data.total }, null, 2),
3079
- }],
3080
- };
3081
- },
3082
- );
3083
-
3084
- server.tool(
3085
- 'install_package',
3086
- [
3087
- 'Install an NPM package on Enfyra. Searches NPM registry for exact version, then creates enfyra_package record.',
3088
- 'Enfyra handles the actual yarn add internally based on type.',
3089
- 'Type "Server" = available in handlers/hooks as $ctx.$pkgs.packageName.',
3090
- 'Type "App" = available in extensions via getPackages().',
3091
- ].join(' '),
3092
- {
3093
- name: z.string().describe('Exact NPM package name (e.g., "node-ssh", "axios")'),
3094
- type: z.enum(['Server', 'App']).default('Server').describe('Where to install: Server (handlers/hooks) or App (extensions)'),
3095
- version: z.string().optional().describe('Specific version. If omitted, fetches latest from NPM.'),
3096
- globalRulesAckKey: globalRulesAckParam(z),
3097
- },
3098
- async ({ name, type, version, globalRulesAckKey }) => {
3099
- assertGlobalRulesAck(globalRulesAckKey);
3100
- // Step 1: Get package info from NPM if version not specified
3101
- let pkgVersion = version;
3102
- let pkgDescription = '';
3103
-
3104
- if (!pkgVersion) {
3105
- const npmUrl = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(name)}&size=5`;
3106
- const npmResponse = await fetch(npmUrl);
3107
- if (!npmResponse.ok) throw new Error(`NPM search failed: ${npmResponse.statusText}`);
3108
- const npmData = await npmResponse.json();
3109
-
3110
- const exactMatch = npmData.objects.find((obj) => obj.package.name === name);
3111
- if (!exactMatch) throw new Error(`Package "${name}" not found on NPM`);
3112
-
3113
- pkgVersion = exactMatch.package.version;
3114
- pkgDescription = exactMatch.package.description || '';
3115
- }
3116
-
3117
- // Step 2: Check if already installed (same name AND type)
3118
- const checkFilter = JSON.stringify({ name: { _eq: name }, type: { _eq: type } });
3119
- const existing = await fetchAPI(ENFYRA_API_URL, `/enfyra_package?filter=${encodeURIComponent(checkFilter)}&limit=1`);
3120
- if (existing.data && existing.data.length > 0) {
3121
- return jsonContent({
3122
- action: 'package_already_installed',
3123
- package: {
3124
- name,
3125
- version: existing.data[0].version,
3126
- type: existing.data[0].type,
3127
- },
3128
- record: existing.data[0],
3129
- });
3130
- }
3131
-
3132
- // Step 3: Get current user for installedBy
3133
- const me = await fetchAPI(ENFYRA_API_URL, '/me');
3134
- const userId = me.data?.[0]?.id || me.data?.[0]?._id;
3135
- if (!userId) throw new Error('Cannot get current user ID');
3136
-
3137
- // Step 4: Install via enfyra_package
3138
- const body = {
3139
- name,
3140
- version: pkgVersion,
3141
- description: pkgDescription,
3142
- type,
3143
- installedBy: { id: userId },
3144
- };
3145
-
3146
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_package', {
3147
- method: 'POST',
3148
- body: JSON.stringify(body),
3149
- });
3150
-
3151
- return jsonContent({
3152
- action: 'package_installed',
3153
- package: { name, version: pkgVersion, type },
3154
- result,
3155
- });
3156
- },
3157
- );
3158
-
3159
- // ============================================================================
3160
- // MAIN
3161
- // ============================================================================
3162
-
3163
- async function main() {
3164
- console.error('Starting Enfyra MCP Server...');
3165
- console.error(`API URL: ${ENFYRA_API_URL}`);
3166
- console.error(`Auth: ${ENFYRA_API_TOKEN ? 'API token configured' : 'Not configured'}`);
3167
-
3168
- const transport = new StdioServerTransport();
3169
- await server.connect(transport);
3170
-
3171
- console.error('Enfyra MCP Server running on stdio');
3172
- }
3173
-
3174
- main().catch((error) => {
3175
- console.error('Fatal error:', error);
3176
- process.exit(1);
3177
- });