@enfyra/mcp-server 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -244,6 +244,8 @@ Use `get_enfyra_examples` from the MCP tool list when asking an LLM to generate
244
244
  - files and storage
245
245
  - Enfyra admin extensions
246
246
 
247
+ Use `discover_enfyra_workflows` when an LLM knows the goal but may not know the right Enfyra tool path. It returns progressive-disclosure workflow matches with first tools, required acknowledgements, verification tools, relevant example categories, and `avoidTools` boundaries that prevent near-correct but unsafe tool choices.
248
+
247
249
  Use `get_enfyra_required_knowledge` before asking an LLM to mutate metadata, schema, routes, permissions, menus, packages, cache state, dynamic server code, or Enfyra extension code. It returns global rules plus acknowledgement keys that write tools verify before saving. Dynamic server code also requires the dynamic-code acknowledgement key, and extension code also requires the extension acknowledgement key.
248
250
 
249
251
  ## Runtime Safety
@@ -262,7 +264,8 @@ The MCP server includes safety guards for LLM callers:
262
264
  - Relation tools reject physical FK/junction names and resolve table ids from exact table names or aliases before schema mutation.
263
265
  - Generated code should use relation property names such as `conversation`, `sender`, and `member` instead of physical FK fields such as `conversationId`, `senderId`, or `memberId`.
264
266
  - Custom route tools reject `mainTableId` unless the route is the canonical table route.
265
- - Platform operation tools such as `api_endpoint_workflow`, `create_api_endpoint`, `enable_route`, `disable_route`, `delete_route`, `public_route_methods`, `add_route_methods`, `set_table_graphql`, `ensure_guard`, `ensure_field_permission`, `ensure_column_rule`, `ensure_websocket_event`, `choose_flow_step_tool`, fixed-type flow step tools, `ensure_menu`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension` resolve metadata ids and validate code before saving.
267
+ - `discover_enfyra_workflows` maps task intent to workflow surfaces before the agent loads detailed examples or guesses between similar tools.
268
+ - Platform operation tools such as `api_endpoint_workflow`, `extension_workflow`, `create_api_endpoint`, `enable_route`, `disable_route`, `delete_route`, `public_route_methods`, `add_route_methods`, `set_table_graphql`, `ensure_guard`, `ensure_field_permission`, `ensure_column_rule`, `ensure_websocket_event`, `choose_flow_step_tool`, fixed-type flow step tools, `ensure_menu`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension` resolve metadata ids and validate code before saving.
266
269
  - Schema changes are serialized.
267
270
  - Destructive deletes return a preview before requiring `confirm=true`.
268
271
 
@@ -300,7 +303,7 @@ Do not create custom login/logout/me routes that manually set Enfyra token cooki
300
303
 
301
304
  ## Tool Summary
302
305
 
303
- The MCP server exposes tools for metadata discovery, required knowledge, examples, query/CRUD, method management, route lifecycle, route access audit/grant, routes, handlers, hooks, tables, columns, relations, cache reloads, logs, users, roles, packages, menus, extensions, scripts, flows, websocket, files, `get_enfyra_api_context`, and `get_enfyra_required_knowledge`.
306
+ The MCP server exposes tools for workflow routing, metadata discovery, required knowledge, examples, query/CRUD, method management, route lifecycle, route access audit/grant, routes, handlers, hooks, tables, columns, relations, cache reloads, logs, users, roles, packages, menus, extensions, scripts, flows, websocket, files, `get_enfyra_api_context`, and `get_enfyra_required_knowledge`.
304
307
 
305
308
  Routes have two separate controls. `isEnabled` controls runtime registration: disabled routes return `404`. Use `enable_route` and `disable_route` for this lifecycle. `publicMethods` controls anonymous access for enabled routes; use `public_route_methods` and `private_route_methods` for that access boundary.
306
309
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,13 +25,14 @@ export function buildMcpServerInstructions(apiBaseUrl) {
25
25
  '',
26
26
  '### Work Flow',
27
27
  '- For a quick target/base sanity check, call `get_enfyra_api_context`; do not call broad discovery just to confirm which instance this MCP is connected to.',
28
+ '- When the task intent is clear but the right tool path is not, call `discover_enfyra_workflows` with the intent, risk, and optional surface. Use `detail: "plan"` before writes to get firstTools, required acknowledgements, verify tools, and avoidTools.',
28
29
  '- Discover before deciding. For architecture/capability questions call `discover_enfyra_system`; for DB/pk/runtime/cache context call `discover_runtime_context`; for filters/deep/sort/relation query shape call `discover_query_capabilities`. Run broad discovery tools sequentially, not in parallel.',
29
30
  '- Inspect narrowly. Use `inspect_table`, `inspect_route`, and `inspect_feature` for the table/route/feature being changed instead of loading broad metadata.',
30
31
  '- Load examples only when needed. Use `get_enfyra_examples` by category. Before extension UI, call `get_extension_theme_contract`; call `get_theme_class_reference` for exact eapp/Nuxt UI theme classes.',
31
32
  '- For server scripts, call `discover_script_contexts` before writing or reviewing handler/hook/flow/websocket/GraphQL logic.',
32
33
  '- Before mutating metadata, schema, routes, permissions, menus, packages, cache state, dynamic code, or extension UI, call `get_enfyra_required_knowledge`, read the global rules, and pass `globalRulesAckKey` into write tools. Dynamic server code also requires `dynamicCodeAckKey`; extension code also requires `extensionAckKey`.',
33
34
  '- With non-root API tokens, call `get_permission_profile` before relying on admin helper tools or when debugging 403s. MCP admin helpers require ordinary route permissions for static admin routes such as `/admin/script/validate`, `/admin/test/run`, `/admin/flow/trigger/:id`, and `/admin/reload/*`.',
34
- '- Prefer the most specific business operation tool over raw metadata CRUD: `api_endpoint_workflow`, `create_api_endpoint`, `enable_route`, `add_route_methods`, `public_route_methods`, `set_table_graphql`, guard/permission/rule tools, websocket tools, flow tools, and `ensure_page_extension`/menu tools.',
35
+ '- Prefer the most specific business operation tool over raw metadata CRUD. `discover_enfyra_workflows` provides the current operation-tool map and negative-routing avoidTools.',
35
36
  '- Before saving standalone dynamic script or extension code, call `validate_dynamic_script` or `validate_extension_code` unless the chosen ensure/update tool already validates the code.',
36
37
  '- For existing script-backed records, use `trace_metadata_usage` then `get_script_source`; edit with `patch_script_source` or `update_script_source` so source is hash-checked and validated.',
37
38
  '- Validate behavior with `test_rest_endpoint`, `run_admin_test`, `test_flow_step`, or the route-specific tool before claiming a dynamic feature works.',
@@ -873,6 +873,18 @@ function sourceMatches(existingHandler, sourceCode, scriptLanguage, timeout) {
873
873
  return true;
874
874
  }
875
875
 
876
+ function extensionMatches(existingExtension, opts, menuId) {
877
+ if (!existingExtension) return false;
878
+ if (String(existingExtension.type || '') !== String(opts.type || 'page')) return false;
879
+ if (String(existingExtension.code ?? '') !== String(opts.code ?? '')) return false;
880
+ if (opts.description !== undefined && String(existingExtension.description || '') !== String(opts.description || '')) return false;
881
+ if (opts.isEnabled !== undefined && Boolean(existingExtension.isEnabled) !== Boolean(opts.isEnabled)) return false;
882
+ if (opts.version !== undefined && String(existingExtension.version || '') !== String(opts.version)) return false;
883
+ if ((opts.type || 'page') === 'page' && menuId && String(refId(existingExtension.menu)) !== String(menuId)) return false;
884
+ if ((opts.type || 'page') !== 'page' && refId(existingExtension.menu)) return false;
885
+ return true;
886
+ }
887
+
876
888
  function step(status, id, title, detail = {}) {
877
889
  return { id, title, status, ...detail };
878
890
  }
@@ -1183,6 +1195,189 @@ async function runApiEndpointWorkflow(apiUrl, opts) {
1183
1195
  };
1184
1196
  }
1185
1197
 
1198
+ async function resolveExtensionWorkflowState(apiUrl, opts) {
1199
+ const type = opts.type || 'page';
1200
+ if (type === 'page' && opts.menuId && (opts.menuLabel || opts.menuPath)) {
1201
+ throw new Error('Provide menuId or menuLabel/menuPath for page extension workflow, not both.');
1202
+ }
1203
+ if (type !== 'page' && (opts.menuId || opts.menuLabel || opts.menuPath)) {
1204
+ throw new Error('Menu fields are only valid for page extensions.');
1205
+ }
1206
+ const validation = await validateExtensionCode(apiUrl, opts.code, opts.name);
1207
+ const existingExtension = await findRecord(apiUrl, 'enfyra_extension', { name: { _eq: opts.name } }, 'id,_id,name,type,menu.id,description,isEnabled,version,code');
1208
+ let menu = null;
1209
+ if (type === 'page' && opts.menuId) {
1210
+ menu = await findRecord(apiUrl, 'enfyra_menu', { id: { _eq: opts.menuId } }, 'id,_id,label,path,type,order,isEnabled');
1211
+ if (!menu) throw new Error(`Menu not found: ${opts.menuId}`);
1212
+ } else if (type === 'page' && (opts.menuPath || opts.menuLabel)) {
1213
+ const normalizedPath = opts.menuPath ? normalizeRestPath(opts.menuPath) : undefined;
1214
+ menu = normalizedPath
1215
+ ? await findRecord(apiUrl, 'enfyra_menu', { path: { _eq: normalizedPath } }, 'id,_id,label,path,type,order,isEnabled')
1216
+ : await findRecord(apiUrl, 'enfyra_menu', { label: { _eq: opts.menuLabel } }, 'id,_id,label,path,type,order,isEnabled');
1217
+ }
1218
+
1219
+ const menuId = opts.menuId || getId(menu);
1220
+ const steps = [];
1221
+ steps.push(step('completed', 'validate_extension', 'Validate extension code', { validation }));
1222
+ if (type === 'page') {
1223
+ if (menuId) {
1224
+ const menuNeedsUpdate = Boolean(menu && (
1225
+ (opts.menuLabel !== undefined && menu.label !== opts.menuLabel)
1226
+ || (opts.menuPath !== undefined && menu.path !== normalizeRestPath(opts.menuPath))
1227
+ || (opts.menuType !== undefined && menu.type !== opts.menuType)
1228
+ || (opts.menuOrder !== undefined && Number(menu.order || 0) !== Number(opts.menuOrder))
1229
+ || (opts.menuIsEnabled !== undefined && Boolean(menu.isEnabled) !== Boolean(opts.menuIsEnabled))
1230
+ ));
1231
+ steps.push(step(menuNeedsUpdate ? 'pending' : 'completed', 'ensure_menu', 'Ensure page menu', {
1232
+ menuId,
1233
+ menu: menu ? { id: getId(menu), label: menu.label, path: menu.path } : { id: menuId },
1234
+ }));
1235
+ } else if (opts.menuLabel) {
1236
+ steps.push(step('pending', 'ensure_menu', 'Create page menu', {
1237
+ reason: 'No existing menu matched; ensure_menu will create it.',
1238
+ }));
1239
+ } else {
1240
+ steps.push(step('blocked', 'ensure_menu', 'Create or select page menu', {
1241
+ reason: 'Page extensions require menuId or menuLabel. Provide menuId for an existing menu or menuLabel/menuPath to create/update one.',
1242
+ }));
1243
+ }
1244
+ }
1245
+
1246
+ const effectiveMenuId = type === 'page' ? menuId : undefined;
1247
+ const saveStatus = steps.some((item) => ['blocked', 'waiting'].includes(item.status))
1248
+ ? 'waiting'
1249
+ : extensionMatches(existingExtension, { ...opts, type }, effectiveMenuId)
1250
+ ? 'completed'
1251
+ : 'pending';
1252
+ steps.push(step(saveStatus, 'save_extension', `Ensure ${type} extension`, {
1253
+ extensionId: getId(existingExtension),
1254
+ currentType: existingExtension?.type || null,
1255
+ desiredType: type,
1256
+ menuId: effectiveMenuId || null,
1257
+ reason: saveStatus === 'waiting' ? 'Menu must exist before saving page extension.' : undefined,
1258
+ }));
1259
+
1260
+ const firstRunnable = steps.find((item) => item.status === 'pending') || null;
1261
+ const blocked = steps.find((item) => item.status === 'blocked') || null;
1262
+ return {
1263
+ extension: {
1264
+ name: opts.name,
1265
+ type,
1266
+ id: getId(existingExtension),
1267
+ menuId: effectiveMenuId || null,
1268
+ },
1269
+ validation,
1270
+ existingExtension: existingExtension ? {
1271
+ id: getId(existingExtension),
1272
+ name: existingExtension.name,
1273
+ type: existingExtension.type,
1274
+ menuId: refId(existingExtension.menu) || null,
1275
+ } : null,
1276
+ menu: menu ? { id: getId(menu), label: menu.label, path: menu.path } : null,
1277
+ steps,
1278
+ firstRunnable,
1279
+ blocked,
1280
+ nextSteps: blocked
1281
+ ? [{ tool: 'extension_workflow', input: { name: opts.name, type }, reason: blocked.reason }]
1282
+ : firstRunnable
1283
+ ? [{
1284
+ tool: 'extension_workflow',
1285
+ input: { name: opts.name, type, apply: true, stepId: firstRunnable.id },
1286
+ stepId: firstRunnable.id,
1287
+ requiresKnowledgeAck: 'globalRulesAckKey and extensionAckKey from get_enfyra_required_knowledge',
1288
+ }]
1289
+ : [],
1290
+ };
1291
+ }
1292
+
1293
+ async function applyExtensionWorkflowStep(apiUrl, state, opts, stepId) {
1294
+ const selectedStep = stepId
1295
+ ? state.steps.find((item) => item.id === stepId)
1296
+ : state.firstRunnable;
1297
+ if (!selectedStep) return { action: 'noop', reason: 'No runnable step remains.' };
1298
+ if (selectedStep.status !== 'pending') {
1299
+ throw new Error(`Step "${selectedStep.id}" is ${selectedStep.status}, not pending.`);
1300
+ }
1301
+
1302
+ const type = opts.type || 'page';
1303
+ if (selectedStep.id === 'ensure_menu') {
1304
+ if (type !== 'page') throw new Error('ensure_menu step is only valid for page extensions.');
1305
+ if (!opts.menuLabel && !opts.menuId) throw new Error('menuLabel or menuId is required for ensure_menu.');
1306
+ return {
1307
+ action: 'menu_ensured',
1308
+ menu: await ensureMenu(apiUrl, {
1309
+ label: opts.menuLabel || state.menu?.label || opts.name,
1310
+ path: opts.menuPath || state.menu?.path,
1311
+ icon: opts.menuIcon,
1312
+ type: opts.menuType,
1313
+ order: opts.menuOrder,
1314
+ permission: opts.menuPermission,
1315
+ description: opts.menuDescription,
1316
+ isEnabled: opts.menuIsEnabled,
1317
+ globalRulesAckKey: opts.globalRulesAckKey,
1318
+ }),
1319
+ };
1320
+ }
1321
+
1322
+ if (selectedStep.id === 'save_extension') {
1323
+ let menuId = opts.menuId || state.extension.menuId;
1324
+ if (type === 'page' && !menuId) {
1325
+ const freshState = await resolveExtensionWorkflowState(apiUrl, opts);
1326
+ menuId = freshState.extension.menuId;
1327
+ }
1328
+ if (type === 'page' && !menuId) throw new Error('Page extension menu is missing. Apply ensure_menu first.');
1329
+ return {
1330
+ action: `${type}_extension_ensured`,
1331
+ extension: await ensureExtension(apiUrl, {
1332
+ name: opts.name,
1333
+ type,
1334
+ code: opts.code,
1335
+ menuId,
1336
+ description: opts.description,
1337
+ isEnabled: opts.isEnabled,
1338
+ version: opts.version,
1339
+ globalRulesAckKey: opts.globalRulesAckKey,
1340
+ extensionKnowledgeAckKey: opts.extensionKnowledgeAckKey,
1341
+ }),
1342
+ };
1343
+ }
1344
+
1345
+ throw new Error(`Unsupported extension workflow step: ${selectedStep.id}`);
1346
+ }
1347
+
1348
+ async function runExtensionWorkflow(apiUrl, opts) {
1349
+ let state = await resolveExtensionWorkflowState(apiUrl, opts);
1350
+ const operations = [];
1351
+ if (opts.apply || opts.applyAll) {
1352
+ assertGlobalRulesAck(opts.globalRulesAckKey);
1353
+ assertExtensionKnowledgeAck(opts.extensionKnowledgeAckKey);
1354
+ const maxSteps = opts.applyAll ? 5 : 1;
1355
+ for (let i = 0; i < maxSteps; i += 1) {
1356
+ if (state.blocked || !state.firstRunnable) break;
1357
+ operations.push(await applyExtensionWorkflowStep(apiUrl, state, opts, opts.stepId));
1358
+ if (!opts.applyAll) break;
1359
+ state = await resolveExtensionWorkflowState(apiUrl, opts);
1360
+ }
1361
+ }
1362
+ const latestState = operations.length ? await resolveExtensionWorkflowState(apiUrl, opts) : state;
1363
+ return {
1364
+ action: operations.length ? 'extension_workflow_advanced' : 'extension_workflow_planned',
1365
+ extension: latestState.extension,
1366
+ validation: latestState.validation,
1367
+ menu: latestState.menu,
1368
+ existingExtension: latestState.existingExtension,
1369
+ steps: latestState.steps,
1370
+ operations,
1371
+ complete: latestState.steps.every((item) => ['completed', 'skipped'].includes(item.status)),
1372
+ nextSteps: latestState.nextSteps,
1373
+ guidance: [
1374
+ 'Call get_extension_theme_contract before generating or reviewing extension UI.',
1375
+ 'For menu/account-panel notifications, use counts only when the signal source already owns an exact count; otherwise use a dot/chip for new attention.',
1376
+ 'Do not fetch destination domain lists solely to decorate the shell; destination pages own domain fetching after click.',
1377
+ ],
1378
+ };
1379
+ }
1380
+
1186
1381
  export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1187
1382
  server.tool(
1188
1383
  'validate_dynamic_script',
@@ -1236,6 +1431,40 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1236
1431
  async () => jsonText(getThemeClassReference()),
1237
1432
  );
1238
1433
 
1434
+ server.tool(
1435
+ 'extension_workflow',
1436
+ [
1437
+ 'Step-by-step workflow for creating or updating Enfyra admin page, global, or widget extensions.',
1438
+ 'Use this when an LLM is building extension UI, menu shell notifications, account panel entries, or page/menu wiring and should follow live nextSteps instead of guessing raw enfyra_extension mutations.',
1439
+ 'With apply=false it validates code, reads live menu/extension state, and returns pending steps.',
1440
+ 'With apply=true it applies exactly the next pending step. With applyAll=true it advances all currently safe pending steps.',
1441
+ 'Call get_extension_theme_contract before generating or reviewing UI.',
1442
+ ].join(' '),
1443
+ {
1444
+ name: z.string().describe('Extension unique name.'),
1445
+ type: z.enum(['page', 'global', 'widget']).optional().default('page').describe('Extension type. Page extensions need a menu. Global extensions are for shell-wide registration.'),
1446
+ code: z.string().describe('Vue SFC extension code.'),
1447
+ menuId: z.union([z.string(), z.number()]).optional().describe('Existing menu id for a page extension. Provide this or menuLabel/menuPath.'),
1448
+ menuLabel: z.string().optional().describe('Menu label to create or update for a page extension when menuId is not provided.'),
1449
+ menuPath: z.string().optional().describe('Admin app route path for the page menu, e.g. /cloud/support.'),
1450
+ menuIcon: z.string().optional().describe('Optional menu icon name.'),
1451
+ menuType: z.enum(['Menu', 'Dropdown Menu']).optional().describe('Menu type. Omit to preserve an existing menu value or use the platform default for a new menu.'),
1452
+ menuOrder: z.number().optional().describe('Menu display order. Omit to preserve an existing menu value or use the platform default for a new menu.'),
1453
+ menuPermission: z.string().optional().describe('Optional menu permission JSON object.'),
1454
+ menuDescription: z.string().optional().describe('Optional menu admin note.'),
1455
+ menuIsEnabled: z.boolean().optional().describe('Enable the menu. Omit to preserve an existing menu value or use the platform default for a new menu.'),
1456
+ description: z.string().optional().describe('Extension description.'),
1457
+ isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
1458
+ version: z.string().optional().default('1.0.0').describe('Extension version.'),
1459
+ apply: z.boolean().optional().default(false).describe('false returns plan only; true applies exactly the next pending step.'),
1460
+ applyAll: z.boolean().optional().default(false).describe('true applies all safe pending steps in order. Prefer apply=true for production changes.'),
1461
+ stepId: z.string().optional().describe('Optional pending step id to apply. Omit to apply the next pending step.'),
1462
+ globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when apply/applyAll mutates metadata. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
1463
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required when apply/applyAll saves extension code. Use extensionAckKey from get_enfyra_required_knowledge.'),
1464
+ },
1465
+ async (input) => jsonText(await runExtensionWorkflow(ENFYRA_API_URL, input)),
1466
+ );
1467
+
1239
1468
  server.tool(
1240
1469
  'set_table_graphql',
1241
1470
  'Business operation: enable or disable GraphQL for one table through enfyra_graphql, then reload GraphQL. REST route methods do not control GraphQL.',
@@ -0,0 +1,582 @@
1
+ export const WORKFLOW_SURFACES = [
2
+ 'api-endpoint',
3
+ 'extension',
4
+ 'schema',
5
+ 'record-data',
6
+ 'dynamic-script',
7
+ 'route-access',
8
+ 'guards-permissions-rules',
9
+ 'flow',
10
+ 'websocket',
11
+ 'graphql',
12
+ 'package',
13
+ 'cache',
14
+ 'logs-debug',
15
+ 'auth-context',
16
+ ];
17
+
18
+ const ALL_DETAILS = ['summary', 'plan', 'full'];
19
+
20
+ export const TOOL_WORKFLOWS = [
21
+ {
22
+ key: 'api-endpoint',
23
+ title: 'Custom REST endpoint with handler',
24
+ useWhen: [
25
+ 'Creating or changing a custom API path with handler behavior.',
26
+ 'Changing public/private method access on a custom endpoint.',
27
+ 'Adding route permissions for endpoint access.',
28
+ ],
29
+ keywords: ['endpoint', 'api', 'route handler', 'handler', 'webhook', 'custom route', 'rest path'],
30
+ firstTools: ['get_enfyra_required_knowledge', 'discover_script_contexts', 'inspect_route'],
31
+ inspectTools: ['inspect_route', 'get_all_routes', 'trace_metadata_usage'],
32
+ knowledgeTools: ['get_enfyra_required_knowledge', 'discover_script_contexts'],
33
+ writeTools: ['api_endpoint_workflow', 'create_api_endpoint', 'enable_route', 'public_route_methods', 'private_route_methods', 'ensure_route_access'],
34
+ verifyTools: ['test_rest_endpoint', 'run_admin_test'],
35
+ avoidTools: [
36
+ {
37
+ tool: 'create_route',
38
+ when: 'a handler-backed endpoint or route permission plan is needed',
39
+ useInstead: 'api_endpoint_workflow',
40
+ reason: 'The workflow validates source, reads live route/handler/access state, applies one safe step at a time, and returns nextSteps.',
41
+ },
42
+ {
43
+ tool: 'create_table',
44
+ when: 'the user asked for custom behavior at an API path',
45
+ useInstead: 'api_endpoint_workflow',
46
+ reason: 'Tables create persisted data models; custom route handlers own behavior endpoints.',
47
+ },
48
+ ],
49
+ requiredAck: ['globalRulesAckKey', 'dynamicCodeAckKey when saving handler source'],
50
+ exampleCategories: ['handlers-hooks', 'permissions-rls'],
51
+ nextStepTemplate: [
52
+ 'Inspect the route or run api_endpoint_workflow with apply=false.',
53
+ 'Read required knowledge before apply/applyAll.',
54
+ 'Apply one pending step or use applyAll only when the plan is fully understood.',
55
+ 'Verify the endpoint with test_rest_endpoint or run_admin_test.',
56
+ ],
57
+ },
58
+ {
59
+ key: 'extension',
60
+ title: 'Admin extension, menu, shell notification, or account panel UI',
61
+ useWhen: [
62
+ 'Creating or changing Enfyra admin page/widget/global extensions.',
63
+ 'Adding menu entries, menu notification dots/counts, account panel rows, or shell actions.',
64
+ 'Reviewing extension UI theme/layout/component usage.',
65
+ ],
66
+ keywords: ['extension', 'menu', 'account panel', 'notification', 'chip', 'badge', 'sidebar', 'shell', 'page ui', 'widget'],
67
+ firstTools: ['get_enfyra_required_knowledge', 'get_extension_theme_contract', 'inspect_feature'],
68
+ inspectTools: ['inspect_feature', 'trace_metadata_usage', 'get_script_source'],
69
+ knowledgeTools: ['get_enfyra_required_knowledge', 'get_extension_theme_contract', 'get_theme_class_reference'],
70
+ writeTools: ['extension_workflow', 'ensure_menu', 'ensure_page_extension', 'ensure_global_extension', 'ensure_widget_extension'],
71
+ verifyTools: ['validate_extension_code', 'inspect_feature'],
72
+ avoidTools: [
73
+ {
74
+ tool: 'create_record/update_record on enfyra_extension',
75
+ when: 'creating or changing extension code',
76
+ useInstead: 'extension_workflow, ensure_page_extension, ensure_global_extension, or ensure_widget_extension',
77
+ reason: 'Workflow and ensure tools validate extension code and preserve extension/menu contracts before saving.',
78
+ },
79
+ {
80
+ tool: 'query_table on destination domain lists',
81
+ when: 'decorating menu/account panel notifications',
82
+ useInstead: 'notification summary/realtime shell signal plus destination-page fetch on click',
83
+ reason: 'Shell notifications should not fetch messages, tickets, orders, or jobs lists solely for a badge.',
84
+ },
85
+ ],
86
+ requiredAck: ['globalRulesAckKey', 'extensionAckKey when saving extension code'],
87
+ exampleCategories: ['extensions'],
88
+ nextStepTemplate: [
89
+ 'Call get_extension_theme_contract before writing or reviewing UI.',
90
+ 'Inspect the existing menu/extension/global shell registration.',
91
+ 'Use extension_workflow with apply=false when page/menu wiring or shell notification behavior needs multiple steps.',
92
+ 'Choose count only when the source already owns an exact count; choose dot/chip for new-attention signals.',
93
+ 'Validate extension code or use an ensure_*_extension tool that validates before saving.',
94
+ ],
95
+ },
96
+ {
97
+ key: 'schema',
98
+ title: 'Table, column, relation, validation, or schema metadata change',
99
+ useWhen: [
100
+ 'Creating or changing tables, columns, relations, indexes, or validation metadata.',
101
+ 'Designing relation direction or inverse relation exposure.',
102
+ 'Changing GraphQL/table schema flags alongside metadata.',
103
+ ],
104
+ keywords: ['schema', 'table', 'column', 'relation', 'field', 'index', 'validation', 'inverse'],
105
+ firstTools: ['get_enfyra_required_knowledge', 'inspect_table', 'get_all_tables'],
106
+ inspectTools: ['inspect_table', 'get_table_metadata', 'get_all_tables'],
107
+ knowledgeTools: ['get_enfyra_required_knowledge', 'get_enfyra_examples'],
108
+ writeTools: ['create_table', 'update_table', 'delete_table', 'create_column', 'update_column', 'delete_column', 'create_relation', 'delete_relation', 'ensure_column_rule'],
109
+ verifyTools: ['inspect_table', 'get_table_metadata'],
110
+ avoidTools: [
111
+ {
112
+ tool: 'create_record/update_record on enfyra_column or enfyra_relation',
113
+ when: 'changing schema metadata',
114
+ useInstead: 'table/column/relation schema tools',
115
+ reason: 'Schema tools resolve table ids, preserve relation contracts, and reject physical FK names.',
116
+ },
117
+ {
118
+ tool: 'manual inversePropertyName',
119
+ when: 'there is no concrete response/UI/deep-query/aggregate need for reverse traversal',
120
+ useInstead: 'owning relation only',
121
+ reason: 'Relation design stays minimal unless the reverse field is actually used.',
122
+ },
123
+ ],
124
+ requiredAck: ['globalRulesAckKey'],
125
+ exampleCategories: ['schema-relations'],
126
+ nextStepTemplate: [
127
+ 'Inspect the existing table and relations.',
128
+ 'Decide owner relation and whether an inverse is actually needed.',
129
+ 'Apply schema tool changes with globalRulesAckKey.',
130
+ 'Re-inspect metadata instead of assuming the saved shape.',
131
+ ],
132
+ },
133
+ {
134
+ key: 'record-data',
135
+ title: 'Route-backed table data query or CRUD',
136
+ useWhen: [
137
+ 'Reading or mutating normal route-backed records.',
138
+ 'Counting records or finding one row by filter.',
139
+ 'Testing filters, fields, deep relations, pagination, or aggregate query shape.',
140
+ ],
141
+ keywords: ['record', 'crud', 'query', 'count', 'filter', 'aggregate', 'deep', 'sort', 'pagination'],
142
+ firstTools: ['inspect_table', 'discover_query_capabilities'],
143
+ inspectTools: ['inspect_table', 'get_table_metadata', 'discover_query_capabilities'],
144
+ knowledgeTools: ['get_enfyra_required_knowledge for writes'],
145
+ writeTools: ['create_record', 'update_record', 'delete_record'],
146
+ verifyTools: ['find_one_record', 'query_table', 'count_records'],
147
+ avoidTools: [
148
+ {
149
+ tool: 'query_table without limit or all=true',
150
+ when: 'listing records',
151
+ useInstead: 'query_table with a bounded limit or all=true for intentional complete reads',
152
+ reason: 'List/query tools require explicit paging intent.',
153
+ },
154
+ {
155
+ tool: 'generic CRUD on internal/no-route system tables',
156
+ when: 'changing schema, sessions, columns, or other no-route internals',
157
+ useInstead: 'specific schema/platform tools',
158
+ reason: 'Generic CRUD is for route-backed tables only.',
159
+ },
160
+ ],
161
+ requiredAck: ['globalRulesAckKey for writes', 'dynamicCodeAckKey for script-backed sourceCode writes', 'extensionAckKey for extension code writes'],
162
+ exampleCategories: ['queries-deep'],
163
+ nextStepTemplate: [
164
+ 'Inspect table metadata and choose fields explicitly.',
165
+ 'Use bounded pagination or all=true deliberately.',
166
+ 'For writes, read required knowledge and use metadata-backed field names only.',
167
+ 'Re-read with explicit fields after mutation when saved shape matters.',
168
+ ],
169
+ },
170
+ {
171
+ key: 'dynamic-script',
172
+ title: 'Dynamic server code: handlers, hooks, scripts, or source patches',
173
+ useWhen: [
174
+ 'Writing or reviewing handler, hook, flow step, websocket, GraphQL, or bootstrap sourceCode.',
175
+ 'Editing an existing script-backed metadata record.',
176
+ 'Debugging macro, repository, or validation behavior.',
177
+ ],
178
+ keywords: ['sourcecode', 'script', 'hook', 'pre hook', 'post hook', 'compiledcode', 'macro', 'repos', 'bootstrap'],
179
+ firstTools: ['get_enfyra_required_knowledge', 'discover_script_contexts', 'trace_metadata_usage'],
180
+ inspectTools: ['trace_metadata_usage', 'get_script_source', 'discover_script_contexts'],
181
+ knowledgeTools: ['get_enfyra_required_knowledge', 'discover_script_contexts'],
182
+ writeTools: ['patch_script_source', 'update_script_source', 'create_handler', 'create_pre_hook', 'create_post_hook', 'api_endpoint_workflow'],
183
+ verifyTools: ['validate_dynamic_script', 'run_admin_test', 'test_rest_endpoint', 'test_flow_step'],
184
+ avoidTools: [
185
+ {
186
+ tool: 'update_record with compiledCode',
187
+ when: 'editing dynamic scripts',
188
+ useInstead: 'patch_script_source or update_script_source with sourceCode',
189
+ reason: 'compiledCode is generated and may differ because macros expand.',
190
+ },
191
+ {
192
+ tool: 'throw new Error for intentional user/domain failures',
193
+ when: 'writing generated dynamic server code',
194
+ useInstead: '@THROW400-style macros or native $ctx.$throw helpers',
195
+ reason: 'Intentional domain errors should use the platform error contract.',
196
+ },
197
+ ],
198
+ requiredAck: ['globalRulesAckKey', 'dynamicCodeAckKey'],
199
+ exampleCategories: ['handlers-hooks'],
200
+ nextStepTemplate: [
201
+ 'Discover script context macros for the surface.',
202
+ 'Read existing source through trace_metadata_usage/get_script_source when patching.',
203
+ 'Validate source before save unless the chosen write tool already validates.',
204
+ 'Verify behavior with the route/test runner that matches the script surface.',
205
+ ],
206
+ },
207
+ {
208
+ key: 'route-access',
209
+ title: 'Authenticated route access and public/private method state',
210
+ useWhen: [
211
+ 'Debugging 403/401 route access.',
212
+ 'Granting role/user access to a route method.',
213
+ 'Changing publicMethods or availableMethods.',
214
+ ],
215
+ keywords: ['permission', '403', '401', 'role', 'route access', 'public method', 'private method', 'available method'],
216
+ firstTools: ['get_permission_profile', 'inspect_route', 'audit_route_access'],
217
+ inspectTools: ['get_permission_profile', 'inspect_route', 'audit_route_access'],
218
+ knowledgeTools: ['get_enfyra_required_knowledge'],
219
+ writeTools: ['ensure_route_access', 'add_route_methods', 'replace_route_methods', 'remove_route_methods', 'public_route_methods', 'private_route_methods', 'enable_route', 'disable_route'],
220
+ verifyTools: ['audit_route_access', 'test_rest_endpoint'],
221
+ avoidTools: [
222
+ {
223
+ tool: 'raw enfyra_route_permission CRUD',
224
+ when: 'granting route access',
225
+ useInstead: 'ensure_route_access',
226
+ reason: 'The operation tool resolves roles/methods and merges existing method grants safely.',
227
+ },
228
+ {
229
+ tool: 'public_route_methods',
230
+ when: 'the desired behavior is authenticated user access',
231
+ useInstead: 'ensure_route_access',
232
+ reason: 'publicMethods grants anonymous access; route permissions grant authenticated access.',
233
+ },
234
+ ],
235
+ requiredAck: ['globalRulesAckKey for writes'],
236
+ exampleCategories: ['permissions-rls'],
237
+ nextStepTemplate: [
238
+ 'Inspect route and permission profile before changing access.',
239
+ 'Decide anonymous publicMethods versus authenticated route permission.',
240
+ 'Use route operation tools instead of raw permission CRUD.',
241
+ 'Audit and test the route after the change.',
242
+ ],
243
+ },
244
+ {
245
+ key: 'guards-permissions-rules',
246
+ title: 'Guards, field permissions, and column validation rules',
247
+ useWhen: [
248
+ 'Adding route guards, guard rules, field permissions, or column rules.',
249
+ 'Restricting field read/write behavior.',
250
+ 'Adding body validation rules at metadata level.',
251
+ ],
252
+ keywords: ['guard', 'field permission', 'column rule', 'validation rule', 'rule', 'rls'],
253
+ firstTools: ['get_enfyra_required_knowledge', 'inspect_table', 'inspect_route'],
254
+ inspectTools: ['inspect_table', 'inspect_route', 'discover_query_capabilities'],
255
+ knowledgeTools: ['get_enfyra_required_knowledge'],
256
+ writeTools: ['ensure_guard', 'ensure_field_permission', 'ensure_column_rule'],
257
+ verifyTools: ['test_rest_endpoint', 'query_table', 'run_admin_test'],
258
+ avoidTools: [
259
+ {
260
+ tool: 'raw create_record on guard/rule tables',
261
+ when: 'a dedicated ensure_* operation exists',
262
+ useInstead: 'ensure_guard, ensure_field_permission, or ensure_column_rule',
263
+ reason: 'Ensure tools resolve ids and preserve the current rule contract.',
264
+ },
265
+ ],
266
+ requiredAck: ['globalRulesAckKey'],
267
+ exampleCategories: ['permissions-rls', 'schema-relations'],
268
+ nextStepTemplate: [
269
+ 'Inspect the target table/route and decide the security boundary first.',
270
+ 'Use the specific ensure_* operation for the rule surface.',
271
+ 'Verify with the route/query behavior the rule is meant to protect.',
272
+ ],
273
+ },
274
+ {
275
+ key: 'flow',
276
+ title: 'Flow, scheduled/manual flow, or flow step',
277
+ useWhen: [
278
+ 'Creating or changing manual/scheduled flows.',
279
+ 'Choosing or writing a flow step.',
280
+ 'Testing or triggering a flow.',
281
+ ],
282
+ keywords: ['flow', 'scheduled', 'manual flow', 'flow step', 'trigger flow', 'workflow'],
283
+ firstTools: ['get_enfyra_required_knowledge', 'choose_flow_step_tool', 'discover_script_contexts'],
284
+ inspectTools: ['inspect_feature', 'query_table'],
285
+ knowledgeTools: ['get_enfyra_required_knowledge', 'discover_script_contexts'],
286
+ writeTools: ['ensure_manual_flow', 'ensure_scheduled_flow', 'ensure_query_flow_step', 'ensure_create_flow_step', 'ensure_update_flow_step', 'ensure_delete_flow_step', 'ensure_http_flow_step', 'ensure_sleep_flow_step', 'ensure_trigger_flow_step', 'ensure_log_flow_step', 'ensure_condition_flow_step', 'ensure_script_flow_step'],
287
+ verifyTools: ['test_flow_step', 'run_admin_test', 'trigger_flow'],
288
+ avoidTools: [
289
+ {
290
+ tool: 'ensure_script_flow_step',
291
+ when: 'a fixed query/create/update/delete/http/sleep/trigger/log step can express the operation',
292
+ useInstead: 'choose_flow_step_tool then the fixed-type ensure_*_flow_step',
293
+ reason: 'Atomic step types are easier to inspect, test, and maintain than oversized scripts.',
294
+ },
295
+ ],
296
+ requiredAck: ['globalRulesAckKey', 'dynamicCodeAckKey for script or condition source'],
297
+ exampleCategories: ['flows'],
298
+ nextStepTemplate: [
299
+ 'Use choose_flow_step_tool before mutating when step type is unclear.',
300
+ 'Prefer fixed-type flow step tools over script steps.',
301
+ 'Validate/test script or condition steps before relying on the flow.',
302
+ 'Trigger manually only after the saved steps are verified.',
303
+ ],
304
+ },
305
+ {
306
+ key: 'websocket',
307
+ title: 'Socket.IO gateway or websocket event',
308
+ useWhen: [
309
+ 'Creating or changing websocket gateways/events.',
310
+ 'Writing websocket handler source.',
311
+ 'Testing websocket event logic.',
312
+ ],
313
+ keywords: ['websocket', 'socket', 'socket.io', 'gateway', 'realtime', 'room'],
314
+ firstTools: ['get_enfyra_required_knowledge', 'discover_script_contexts', 'inspect_feature'],
315
+ inspectTools: ['inspect_feature', 'discover_script_contexts'],
316
+ knowledgeTools: ['get_enfyra_required_knowledge', 'discover_script_contexts'],
317
+ writeTools: ['ensure_websocket_gateway', 'ensure_websocket_event'],
318
+ verifyTools: ['run_admin_test'],
319
+ avoidTools: [
320
+ {
321
+ tool: 'raw CRUD on enfyra_websocket_event',
322
+ when: 'saving websocket event source',
323
+ useInstead: 'ensure_websocket_event',
324
+ reason: 'The operation tool validates script source and preserves gateway/event linkage.',
325
+ },
326
+ ],
327
+ requiredAck: ['globalRulesAckKey', 'dynamicCodeAckKey when saving event source'],
328
+ exampleCategories: ['websocket'],
329
+ nextStepTemplate: [
330
+ 'Discover websocket script context before writing source.',
331
+ 'Ensure gateway first, then event.',
332
+ 'Use run_admin_test for event/connection scripts where possible.',
333
+ ],
334
+ },
335
+ {
336
+ key: 'graphql',
337
+ title: 'GraphQL enablement and query surface',
338
+ useWhen: [
339
+ 'Enabling/disabling GraphQL for a table.',
340
+ 'Checking GraphQL endpoint/schema behavior.',
341
+ 'Clarifying REST route methods versus GraphQL table exposure.',
342
+ ],
343
+ keywords: ['graphql', 'gql', 'schema endpoint'],
344
+ firstTools: ['discover_enfyra_system', 'inspect_table'],
345
+ inspectTools: ['discover_enfyra_system', 'inspect_table'],
346
+ knowledgeTools: ['get_enfyra_required_knowledge for writes'],
347
+ writeTools: ['set_table_graphql', 'update_table'],
348
+ verifyTools: ['reload_graphql', 'discover_enfyra_system'],
349
+ avoidTools: [
350
+ {
351
+ tool: 'public_route_methods',
352
+ when: 'trying to expose GraphQL table data',
353
+ useInstead: 'set_table_graphql plus GraphQL auth planning',
354
+ reason: 'REST publicMethods do not make GraphQL table data anonymous.',
355
+ },
356
+ ],
357
+ requiredAck: ['globalRulesAckKey for writes'],
358
+ exampleCategories: ['queries-deep'],
359
+ nextStepTemplate: [
360
+ 'Inspect the table and GraphQL enablement state.',
361
+ 'Use set_table_graphql for enablement changes.',
362
+ 'Remember GraphQL table data requires Bearer auth even when REST is public.',
363
+ ],
364
+ },
365
+ {
366
+ key: 'package',
367
+ title: 'Runtime package install or package-backed extension/script support',
368
+ useWhen: [
369
+ 'Installing npm packages for dynamic code or extension runtime use.',
370
+ 'Checking package availability before using getPackages in extensions.',
371
+ ],
372
+ keywords: ['package', 'npm', 'install', 'dependency', 'getpackages'],
373
+ firstTools: ['search_npm', 'get_enfyra_required_knowledge'],
374
+ inspectTools: ['search_npm', 'query_table'],
375
+ knowledgeTools: ['get_enfyra_required_knowledge'],
376
+ writeTools: ['install_package'],
377
+ verifyTools: ['query_table'],
378
+ avoidTools: [
379
+ {
380
+ tool: 'raw create_record on enfyra_package',
381
+ when: 'installing packages',
382
+ useInstead: 'install_package',
383
+ reason: 'The package tool resolves package metadata and avoids duplicate package records.',
384
+ },
385
+ ],
386
+ requiredAck: ['globalRulesAckKey'],
387
+ exampleCategories: ['extensions', 'handlers-hooks'],
388
+ nextStepTemplate: [
389
+ 'Search package metadata first.',
390
+ 'Install with install_package and globalRulesAckKey.',
391
+ 'Use getPackages inside extension runtime code rather than static imports.',
392
+ ],
393
+ },
394
+ {
395
+ key: 'cache',
396
+ title: 'Cache reload or stale metadata/runtime diagnosis',
397
+ useWhen: [
398
+ 'Diagnosing stale routes, metadata, GraphQL, or full runtime cache.',
399
+ 'Manually reloading only after natural partial reload appears stale.',
400
+ ],
401
+ keywords: ['cache', 'reload', 'stale', 'refresh metadata', 'reload routes'],
402
+ firstTools: ['inspect_table', 'inspect_route', 'get_enfyra_api_context'],
403
+ inspectTools: ['inspect_table', 'inspect_route', 'get_enfyra_api_context'],
404
+ knowledgeTools: ['get_enfyra_required_knowledge for manual reloads'],
405
+ writeTools: ['reload_metadata', 'reload_routes', 'reload_graphql', 'reload_all'],
406
+ verifyTools: ['inspect_table', 'inspect_route', 'discover_enfyra_system'],
407
+ avoidTools: [
408
+ {
409
+ tool: 'manual reload tools',
410
+ when: 'a successful metadata mutation already triggered natural partial reload and no stale evidence exists',
411
+ useInstead: 'verify behavior first with inspect/test tools',
412
+ reason: 'Manual reloads should be evidence-driven, not reflexive.',
413
+ },
414
+ ],
415
+ requiredAck: ['globalRulesAckKey for manual reload tools'],
416
+ exampleCategories: [],
417
+ nextStepTemplate: [
418
+ 'Verify stale behavior with narrow inspect/test tools.',
419
+ 'Choose the narrowest reload surface if stale evidence exists.',
420
+ 'Re-verify the same narrow behavior after reload.',
421
+ ],
422
+ },
423
+ {
424
+ key: 'logs-debug',
425
+ title: 'Logs, runtime diagnostics, and test runner debugging',
426
+ useWhen: [
427
+ 'Reading app/error logs.',
428
+ 'Searching logs for route, flow, websocket, or dynamic script failures.',
429
+ 'Running admin tests for supported runtime surfaces.',
430
+ ],
431
+ keywords: ['log', 'debug', 'error', 'trace', 'tail', 'diagnostic', 'test runner'],
432
+ firstTools: ['get_log_files', 'search_logs'],
433
+ inspectTools: ['get_log_files', 'get_log_content', 'tail_log', 'search_logs'],
434
+ knowledgeTools: [],
435
+ writeTools: [],
436
+ verifyTools: ['run_admin_test', 'test_flow_step', 'test_rest_endpoint'],
437
+ avoidTools: [
438
+ {
439
+ tool: 'broad metadata discovery',
440
+ when: 'the problem is a concrete runtime error with known log text',
441
+ useInstead: 'search_logs or tail_log',
442
+ reason: 'Log tools are narrower and cheaper for runtime debugging.',
443
+ },
444
+ ],
445
+ requiredAck: [],
446
+ exampleCategories: [],
447
+ nextStepTemplate: [
448
+ 'Search or tail the narrowest log first.',
449
+ 'Use the matching test tool to reproduce once the failing surface is known.',
450
+ 'Patch only after the failing step is identified.',
451
+ ],
452
+ },
453
+ {
454
+ key: 'auth-context',
455
+ title: 'MCP target, auth token, current user, and permission profile',
456
+ useWhen: [
457
+ 'Confirming which Enfyra instance MCP is connected to.',
458
+ 'Debugging API-token exchange or current MCP user permissions.',
459
+ 'Checking whether non-root tokens can call admin helper routes.',
460
+ ],
461
+ keywords: ['auth', 'token', 'current user', 'permission profile', 'target', 'api base', '403', 'exchange'],
462
+ firstTools: ['get_enfyra_api_context', 'get_current_user', 'get_permission_profile'],
463
+ inspectTools: ['get_enfyra_api_context', 'get_current_user', 'get_permission_profile'],
464
+ knowledgeTools: [],
465
+ writeTools: ['login'],
466
+ verifyTools: ['get_current_user', 'get_permission_profile'],
467
+ avoidTools: [
468
+ {
469
+ tool: 'discover_enfyra_system',
470
+ when: 'only confirming the connected API base',
471
+ useInstead: 'get_enfyra_api_context',
472
+ reason: 'Target sanity checks should be cheap and should not load broad metadata.',
473
+ },
474
+ ],
475
+ requiredAck: [],
476
+ exampleCategories: ['ssr-app-auth', 'oauth-setup'],
477
+ nextStepTemplate: [
478
+ 'Use get_enfyra_api_context for target sanity checks.',
479
+ 'Use get_permission_profile before assuming admin helper route access with non-root tokens.',
480
+ 'Use login only when an interactive credential login is explicitly needed.',
481
+ ],
482
+ },
483
+ ];
484
+
485
+ function compactWorkflow(workflow) {
486
+ return {
487
+ key: workflow.key,
488
+ title: workflow.title,
489
+ useWhen: workflow.useWhen,
490
+ };
491
+ }
492
+
493
+ function planWorkflow(workflow) {
494
+ return {
495
+ ...compactWorkflow(workflow),
496
+ firstTools: workflow.firstTools,
497
+ inspectTools: workflow.inspectTools,
498
+ knowledgeTools: workflow.knowledgeTools,
499
+ writeTools: workflow.writeTools,
500
+ verifyTools: workflow.verifyTools,
501
+ requiredAck: workflow.requiredAck,
502
+ exampleCategories: workflow.exampleCategories,
503
+ nextSteps: workflow.nextStepTemplate,
504
+ avoidTools: workflow.avoidTools,
505
+ };
506
+ }
507
+
508
+ function fullWorkflow(workflow) {
509
+ return {
510
+ ...planWorkflow(workflow),
511
+ keywords: workflow.keywords,
512
+ };
513
+ }
514
+
515
+ function normalize(value) {
516
+ return String(value || '').trim().toLowerCase();
517
+ }
518
+
519
+ function scoreWorkflow(workflow, { intent, surface, risk }) {
520
+ let score = 0;
521
+ const text = normalize(intent);
522
+ if (surface && workflow.key === surface) score += 20;
523
+ if (surface && workflow.key.includes(surface)) score += 8;
524
+ if (text) {
525
+ for (const keyword of workflow.keywords || []) {
526
+ if (text.includes(keyword)) score += 4;
527
+ }
528
+ for (const phrase of workflow.useWhen || []) {
529
+ const words = normalize(phrase).split(/\W+/).filter((word) => word.length > 3);
530
+ if (words.some((word) => text.includes(word))) score += 1;
531
+ }
532
+ }
533
+ if (risk === 'debug' && workflow.key === 'logs-debug') score += 6;
534
+ if (risk === 'write' && workflow.writeTools.length) score += 2;
535
+ if (risk === 'destructive' && workflow.avoidTools.some((item) => normalize(item.when).includes('delete'))) score += 2;
536
+ return score;
537
+ }
538
+
539
+ export function listWorkflowSurfaces() {
540
+ return TOOL_WORKFLOWS.map(compactWorkflow);
541
+ }
542
+
543
+ export function discoverWorkflowRoutes({
544
+ intent = '',
545
+ surface,
546
+ risk = 'unknown',
547
+ detail = 'summary',
548
+ limit = 5,
549
+ } = {}) {
550
+ const normalizedSurface = surface ? normalize(surface) : undefined;
551
+ const normalizedDetail = ALL_DETAILS.includes(detail) ? detail : 'summary';
552
+ const normalizedRisk = normalize(risk) || 'unknown';
553
+ const formatter = normalizedDetail === 'full'
554
+ ? fullWorkflow
555
+ : normalizedDetail === 'plan'
556
+ ? planWorkflow
557
+ : compactWorkflow;
558
+ const scored = TOOL_WORKFLOWS
559
+ .map((workflow) => ({ workflow, score: scoreWorkflow(workflow, { intent, surface: normalizedSurface, risk: normalizedRisk }) }))
560
+ .filter((item) => !normalizedSurface || item.workflow.key === normalizedSurface || item.score > 0)
561
+ .sort((a, b) => b.score - a.score || a.workflow.key.localeCompare(b.workflow.key));
562
+ const selected = (scored.length ? scored : TOOL_WORKFLOWS.map((workflow) => ({ workflow, score: 0 })))
563
+ .slice(0, Math.max(1, Math.min(Number(limit) || 5, 10)));
564
+ return {
565
+ action: 'enfyra_workflows_discovered',
566
+ intent: intent || null,
567
+ requestedSurface: surface || null,
568
+ risk: normalizedRisk,
569
+ detail: normalizedDetail,
570
+ matchedWorkflowCount: scored.length,
571
+ workflows: selected.map((item) => ({
572
+ score: item.score,
573
+ ...formatter(item.workflow),
574
+ })),
575
+ surfaces: normalizedDetail === 'summary' ? WORKFLOW_SURFACES : undefined,
576
+ guidance: [
577
+ 'Use this as progressive disclosure: pick the closest workflow, then call its firstTools instead of loading every Enfyra tool/example.',
578
+ 'For writes, call get_enfyra_required_knowledge and pass the returned acknowledgement keys into write tools.',
579
+ 'Treat avoidTools as negative routing boundaries; they prevent near-correct tool choices from crossing the wrong platform contract.',
580
+ ],
581
+ };
582
+ }
@@ -20,6 +20,7 @@ import { exchangeApiToken, refreshAccessToken, getValidToken, resetTokens, getTo
20
20
  import { fetchAPI, validateFilter, validateTableName } from './lib/fetch.js';
21
21
  import { buildMcpServerInstructions, buildGraphqlUrls } from './lib/mcp-instructions.js';
22
22
  import { getExamples, listExampleCategories } from './lib/mcp-examples.js';
23
+ import { WORKFLOW_SURFACES, discoverWorkflowRoutes } from './lib/tool-routing.js';
23
24
  import { registerTableTools } from './lib/table-tools.js';
24
25
  import { registerPlatformOperationTools } from './lib/platform-operation-tools.js';
25
26
  import { parseRecordData, prepareRecordMutation, validateScriptSourceIfPresent } from './lib/mutation-guards.js';
@@ -860,6 +861,23 @@ server.tool(
860
861
  },
861
862
  );
862
863
 
864
+ server.tool(
865
+ 'discover_enfyra_workflows',
866
+ [
867
+ 'Progressive-disclosure router for the Enfyra MCP tool surface.',
868
+ 'Call this when the task intent is clear but the right Enfyra tool path is not.',
869
+ 'Returns matched workflows, first tools, required acknowledgement keys, verification tools, and avoidTools negative-routing boundaries.',
870
+ ].join(' '),
871
+ {
872
+ intent: z.string().optional().describe('Plain-language task goal, e.g. "add a menu chip when support tickets arrive".'),
873
+ surface: z.enum(WORKFLOW_SURFACES).optional().describe('Known surface when the caller can classify the task. Omit to infer from intent.'),
874
+ risk: z.enum(['read', 'write', 'destructive', 'debug', 'unknown']).optional().default('unknown').describe('Highest expected operation risk.'),
875
+ 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.'),
876
+ limit: z.number().int().positive().max(10).optional().default(5).describe('Maximum workflows to return.'),
877
+ },
878
+ async (input) => jsonContent(discoverWorkflowRoutes(input)),
879
+ );
880
+
863
881
  server.tool(
864
882
  'discover_enfyra_system',
865
883
  [