@enfyra/mcp-server 0.1.6 → 0.1.8

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,6 +264,7 @@ 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.
267
+ - `discover_enfyra_workflows` maps task intent to workflow surfaces before the agent loads detailed examples or guesses between similar tools.
265
268
  - 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.
266
269
  - Schema changes are serialized.
267
270
  - Destructive deletes return a preview before requiring `confirm=true`.
@@ -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.6",
3
+ "version": "0.1.8",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1597,26 +1597,14 @@ ensure_page_extension({
1597
1597
  'Use enfyra_menu.label, not title.',
1598
1598
  'Sensitive admin menus should include a permission condition at creation time.',
1599
1599
  'For page extensions, create the menu first with ensure_menu and pass its id to ensure_page_extension.',
1600
- 'Call get_extension_theme_contract before writing or reviewing page/widget/global extension UI.',
1600
+ 'Call get_extension_theme_contract before writing or reviewing page/widget/global extension UI; that tool is the authority for theme, color, layout, modal, drawer, and shell registry details.',
1601
1601
  'Call get_enfyra_required_knowledge before saving extension code, pass globalRulesAckKey as globalRulesAckKey, and pass extensionAckKey as extensionKnowledgeAckKey.',
1602
1602
  'Page extensions must register the app-shell PageHeader with usePageHeaderRegistry instead of rendering a custom top header.',
1603
- 'Use variant: "minimal" for operational pages unless a larger header is intentionally needed.',
1604
- 'Do not put ordinary KPI cards in PageHeader.stats; render metrics in the extension body.',
1605
1603
  'Put page-level actions in useHeaderActionRegistry or useSubHeaderActionRegistry, destructure register first, then call it with one action or an array.',
1606
- 'Page extensions should be full-bleed by default and responsive from the first version.',
1607
- 'The extension root is already inside Enfyra admin page main; do not add root-level page padding.',
1608
- 'Use eApp theme class tokens for panels, rows, badges, borders, controls, radius, and text. Generated extension templates should prefer eapp-surface-*, eapp-text-*, eapp-radius-*, eapp-divide-y, and eapp-primary-* over raw CSS variable utilities.',
1609
- 'Treat primary color as runtime-configurable by the app color picker. For Nuxt UI components, choose color="primary" by semantic intent. For custom extension UI, decide whether each element is neutral surface, runtime-primary identity, or status: regular panels/KPI cards/list rows use eapp-surface-card/eapp-surface-muted/eapp-surface-hover/eapp-divide-y and eapp-text-* classes, while selected/current identity blocks, primary tiles, progress fills, primary icons, and primary CTA fills use eapp-primary-surface, eapp-primary-soft, eapp-primary-subtle, eapp-primary-solid, or eapp-primary-text so the color picker controls them. eapp-primary-surface supplies selected identity color but does not replace card chrome; keep border/radius classes on selected blocks.',
1610
- 'Decision cases: normal decorative accents, feature icons, non-state tiles, active tabs, progress fills, selected segments, and primary actions use runtime primary/identity classes; true semantic states use their matching status colors such as error, warning, success, or info; large ordinary surfaces stay neutral and carry only small badges/icons for status; selected/current entity blocks may use eapp-primary-surface.',
1611
- 'Pattern examples: KPI/metric cards should be eapp-surface-card with a small icon tile using eapp-primary-soft eapp-icon-tile; selected/current entity cards may use eapp-primary-surface; progress bars use eapp-surface-muted tracks plus eapp-primary-solid fills; list rows use eapp-surface-card/eapp-divide-y/eapp-surface-hover and only small chips inside; primary scope actions use UButton color="primary" variant="solid"; secondary actions use neutral variants.',
1612
- 'Status colors belong only in UBadge/UAlert semantic colors or eapp-status-*-soft/text/border classes for badges, small icons, or short status text. Do not read --badge-* variables directly in extension templates. Do not color large panels, alert-like success blocks, KPI cards, list containers, or attention/reconciliation blocks green/yellow/red because of state; keep the block neutral and place the status badge/icon inside.',
1613
- 'Use PageHeader gradient: "none" for generated operational pages unless the user explicitly asks for a decorative page accent; do not hardcode cyan/violet/purple/blue/green gradients.',
1614
- 'For general card grids inside the shell, use md:grid-cols-2 xl:grid-cols-3 instead of lg:grid-cols-3 because the desktop sidebar leaves tablet-width content at 1024px.',
1615
- 'Do not use Nuxt UI neutral semantic classes such as bg-default, text-muted, text-dimmed, border-default, or divide-default inside extension code; use eApp class tokens instead. Do not write text-[var(...)], bg-[var(...)], or border-[var(...)] in generated extension templates unless no class token exists for that exact primitive.',
1616
- 'Do not pass ui.content: "eapp-surface-card" to UModal/CommonModal; modal content uses the app modal surface and caller content classes should only append z-index or width.',
1617
- 'CommonModal and CommonDrawer own action-only footers through cancelAction, primaryAction, dangerAction, leadingActions, and footerHint. Pass action intent through props instead of styling footer buttons manually; cancelAction defaults to neutral outline, dangerAction is for irreversible destructive work, and Keep editing should use tone: "primary" in discard dialogs.',
1618
- 'Use UTabs for page sections instead of custom tab bars so the app-level active/inactive indicators, spacing, focus rings, and theme contrast stay consistent.',
1619
- 'Do not inject global CSS, create theme guards, redefine the app palette, or solve one extension by overriding the whole app shell.',
1604
+ 'Page extensions should be full-bleed and responsive from the first version; the extension root is already inside the Enfyra admin page main.',
1605
+ 'Render ordinary metrics and lists in the body, not PageHeader.stats, unless the user explicitly wants a compact overview header.',
1606
+ 'Use app theme tokens and Nuxt UI semantic colors by intent; do not hard-code concrete palettes or redefine the app palette inside extension code.',
1607
+ 'Use app-owned primitives such as UTabs, CommonModal, CommonDrawer, Widget, useMenuNotificationRegistry, and useAccountPanelRegistry when the workflow matches them.',
1620
1608
  'Keep list selection local and fetch detail rows only; do not refetch the whole list after a row click unless the list data changed.',
1621
1609
  'Page extension paths are admin app UI routes. Do not verify them with test_rest_endpoint against ENFYRA_API_URL unless inspect_route shows an API route with the same path.',
1622
1610
  'After saving, open Enfyra admin tabs should update through the server/Enfyra admin UI realtime reload contract; do not tell the user to refresh unless that contract is proven broken.',
@@ -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.',
@@ -1441,7 +1441,8 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1441
1441
  'create_api_endpoint',
1442
1442
  [
1443
1443
  'Business operation: create or update a custom REST endpoint with a handler in one safe operation.',
1444
- 'Use this when the user asks for a new route/endpoint/API path that computes or orchestrates behavior, such as GET /sum or POST /webhook.',
1444
+ 'Prefer api_endpoint_workflow when route access, role/user permissions, overwrite decisions, or multi-step planning matter.',
1445
+ 'Use this one-shot helper only when the endpoint contract is already clear and no authenticated route-permission step is needed in the same operation, such as a simple public webhook or private admin-only utility that will be granted separately.',
1445
1446
  'It creates the route without mainTableId, ensures the method is available, validates sourceCode, creates or overwrites the route handler, optionally makes the method public, reloads routes, and can smoke-test the endpoint.',
1446
1447
  'Use table/schema tools separately when the user needs persisted data. This tool is for custom behavior endpoints.',
1447
1448
  ].join(' '),
@@ -1591,8 +1592,10 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1591
1592
  message: z.string().optional().describe('Custom validation error message.'),
1592
1593
  description: z.string().optional().describe('Admin note.'),
1593
1594
  isEnabled: z.boolean().optional().default(true).describe('Enable the rule.'),
1595
+ globalRulesAckKey: globalRulesAckParam(z),
1594
1596
  },
1595
- async ({ tableName, columnName, ruleType, value, message, description, isEnabled }) => {
1597
+ async ({ tableName, columnName, ruleType, value, message, description, isEnabled, globalRulesAckKey }) => {
1598
+ assertGlobalRulesAck(globalRulesAckKey);
1596
1599
  const table = resolveTable(await getMetadataTables(ENFYRA_API_URL), tableName);
1597
1600
  const column = resolveColumn(table, columnName);
1598
1601
  const existing = await findRecord(ENFYRA_API_URL, 'enfyra_column_rule', {
@@ -1632,8 +1635,10 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1632
1635
  condition: z.string().optional().describe('Condition JSON object using field permission DSL.'),
1633
1636
  description: z.string().optional().describe('Admin note.'),
1634
1637
  isEnabled: z.boolean().optional().default(true).describe('Enable the permission.'),
1638
+ globalRulesAckKey: globalRulesAckParam(z),
1635
1639
  },
1636
- async ({ tableName, columnName, relationName, action, effect, roleId, roleName, allowedUserIds, condition, description, isEnabled }) => {
1640
+ async ({ tableName, columnName, relationName, action, effect, roleId, roleName, allowedUserIds, condition, description, isEnabled, globalRulesAckKey }) => {
1641
+ assertGlobalRulesAck(globalRulesAckKey);
1637
1642
  if (!!columnName === !!relationName) throw new Error('Provide exactly one of columnName or relationName.');
1638
1643
  assertOneScope({ roleId, roleName, allowedUserIds });
1639
1644
  const [tables, role] = await Promise.all([
@@ -1691,8 +1696,10 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1691
1696
  description: z.string().optional().describe('Admin note.'),
1692
1697
  rules: z.string().optional().describe('Rules JSON array: [{type, config, priority, isEnabled, description, userIds}].'),
1693
1698
  rulesMode: z.enum(['append', 'replace', 'none']).optional().default('append').describe('append creates rules, replace disables existing rules first, none leaves rules unchanged.'),
1699
+ globalRulesAckKey: globalRulesAckParam(z),
1694
1700
  },
1695
- async ({ name, guardId, position, routeId, path, methods, combinator, priority, isGlobal, isEnabled, description, rules, rulesMode }) => {
1701
+ async ({ name, guardId, position, routeId, path, methods, combinator, priority, isGlobal, isEnabled, description, rules, rulesMode, globalRulesAckKey }) => {
1702
+ assertGlobalRulesAck(globalRulesAckKey);
1696
1703
  if (path && routeId) throw new Error('Provide path or routeId, not both.');
1697
1704
  const ruleInputs = parseJsonArrayArg('rules', rules, []);
1698
1705
  if (position === 'pre_auth') {
@@ -49,7 +49,7 @@ export function assertExtensionKnowledgeAckIf(condition, key) {
49
49
  export function buildRequiredKnowledgePayload() {
50
50
  return {
51
51
  version: REQUIRED_KNOWLEDGE_VERSION,
52
- purpose: 'Read this before mutating Enfyra metadata, schema, routes, permissions, dynamic server code, or extension UI through MCP.',
52
+ purpose: 'Read this before mutating Enfyra metadata, schema, routes, permissions, menus, packages, cache state, dynamic server code, or extension UI through MCP.',
53
53
  globalRulesAckKey: GLOBAL_RULES_ACK_KEY,
54
54
  dynamicCodeAckKey: DYNAMIC_CODE_KNOWLEDGE_ACK_KEY,
55
55
  extensionAckKey: EXTENSION_KNOWLEDGE_ACK_KEY,
@@ -313,7 +313,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
313
313
  return withSchemaQueue(async () => {
314
314
  const tableData = await fetchTableWithDetails(ENFYRA_API_URL, args.tableId);
315
315
  if (!tableData) {
316
- return { content: [{ type: 'text', text: `Error: Table with ID ${args.tableId} not found.` }] };
316
+ throw new Error(`Table with ID ${args.tableId} not found.`);
317
317
  }
318
318
 
319
319
  const existingColumns = getPatchableColumns(tableData.columns);
@@ -325,9 +325,12 @@ export function registerTableTools(server, ENFYRA_API_URL) {
325
325
  columnName: args.name,
326
326
  });
327
327
 
328
- return {
329
- content: [{ type: 'text', text: `Column "${args.name}" added to table ${args.tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
330
- };
328
+ return jsonContent({
329
+ action: 'column_created',
330
+ tableId: args.tableId,
331
+ columnName: args.name,
332
+ result,
333
+ });
331
334
  });
332
335
  }
333
336
 
@@ -341,7 +344,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
341
344
  const resolvedTargetTableId = resolveTableIdentifierFromMetadata(metadata, targetTableId, 'targetTableId');
342
345
  const tableData = await fetchTableWithDetails(ENFYRA_API_URL, resolvedSourceTableId);
343
346
  if (!tableData) {
344
- return { content: [{ type: 'text', text: `Error: Table ${sourceTableId} not found.` }] };
347
+ throw new Error(`Table ${sourceTableId} not found.`);
345
348
  }
346
349
  const existingRelations = (tableData.relations || []).map(sanitizeExistingRelationForTablePatch);
347
350
  const beforeIds = existingRelations.map((relation) => String(getId(relation))).filter((id) => id !== 'null');
@@ -356,9 +359,11 @@ export function registerTableTools(server, ENFYRA_API_URL) {
356
359
  action: 'create',
357
360
  propertyName,
358
361
  });
359
- return {
360
- content: [{ type: 'text', text: `Relation created: ${propertyName} (${type}) from table ${resolvedSourceTableId} → ${resolvedTargetTableId}.\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
361
- };
362
+ return jsonContent({
363
+ action: 'relation_created',
364
+ relation: { propertyName, type, sourceTableId: resolvedSourceTableId, targetTableId: resolvedTargetTableId },
365
+ result,
366
+ });
362
367
  });
363
368
  }
364
369
 
@@ -366,7 +371,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
366
371
  return withSchemaQueue(async () => {
367
372
  const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
368
373
  if (!tableData) {
369
- return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
374
+ throw new Error(`Table with ID ${tableId} not found.`);
370
375
  }
371
376
 
372
377
  const existingColumns = getPatchableColumns(tableData.columns);
@@ -399,9 +404,12 @@ export function registerTableTools(server, ENFYRA_API_URL) {
399
404
  columnId,
400
405
  });
401
406
 
402
- return {
403
- content: [{ type: 'text', text: `Column ${columnId} deleted from table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
404
- };
407
+ return jsonContent({
408
+ action: 'column_deleted',
409
+ tableId,
410
+ columnId,
411
+ result,
412
+ });
405
413
  });
406
414
  }
407
415
 
@@ -409,7 +417,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
409
417
  return withSchemaQueue(async () => {
410
418
  const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
411
419
  if (!tableData) {
412
- return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
420
+ throw new Error(`Table with ID ${tableId} not found.`);
413
421
  }
414
422
 
415
423
  const existingRelations = (tableData.relations || []).map(sanitizeExistingRelationForTablePatch);
@@ -442,9 +450,12 @@ export function registerTableTools(server, ENFYRA_API_URL) {
442
450
  relationId,
443
451
  });
444
452
 
445
- return {
446
- content: [{ type: 'text', text: `Relation ${relationId} deleted from table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
447
- };
453
+ return jsonContent({
454
+ action: 'relation_deleted',
455
+ tableId,
456
+ relationId,
457
+ result,
458
+ });
448
459
  });
449
460
  }
450
461
 
@@ -613,9 +624,25 @@ export function registerTableTools(server, ENFYRA_API_URL) {
613
624
  indexes.length ? `Index group(s): ${indexes.length}.` : null,
614
625
  uniques.length ? `Unique group(s): ${uniques.length}.` : null,
615
626
  ].filter(Boolean).join(' ');
616
- return {
617
- content: [{ type: 'text', text: `${colHint}\n${relHint}${constraintHint ? `\n${constraintHint}` : ''}\n${restHint}\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
618
- };
627
+ return jsonContent({
628
+ action: 'table_created',
629
+ table: { id: createdTableId, name, routePath },
630
+ summary: {
631
+ columnCount: userColumns.length + 1,
632
+ createdColumnCount: userColumns.length,
633
+ relationCount: userRelations.length,
634
+ indexGroupCount: indexes.length,
635
+ uniqueGroupCount: uniques.length,
636
+ },
637
+ rest: {
638
+ base,
639
+ routePath,
640
+ operations: ['GET /<table>', 'POST /<table>', 'PATCH /<table>/:id', 'DELETE /<table>/:id'],
641
+ noGetById: true,
642
+ },
643
+ message: [colHint, relHint, constraintHint, restHint].filter(Boolean).join('\n'),
644
+ result,
645
+ });
619
646
  })
620
647
  );
621
648
 
@@ -652,9 +679,11 @@ export function registerTableTools(server, ENFYRA_API_URL) {
652
679
  if (uniquesJson !== undefined) body.uniques = normalizeConstraintGroups('uniques', parseJsonArrayParam('uniques', uniquesJson));
653
680
 
654
681
  const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, body);
655
- return {
656
- content: [{ type: 'text', text: `Table ${tableId} updated.\n\n${JSON.stringify(result, null, 2)}` }],
657
- };
682
+ return jsonContent({
683
+ action: 'table_updated',
684
+ tableId,
685
+ result,
686
+ });
658
687
  })
659
688
  );
660
689
 
@@ -691,9 +720,11 @@ export function registerTableTools(server, ENFYRA_API_URL) {
691
720
  const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_table/${tableId}`, {
692
721
  method: 'DELETE',
693
722
  });
694
- return {
695
- content: [{ type: 'text', text: `Table ${tableId} deleted.\n\n${JSON.stringify(result, null, 2)}` }],
696
- };
723
+ return jsonContent({
724
+ action: 'table_deleted',
725
+ tableId,
726
+ result,
727
+ });
697
728
  })
698
729
  );
699
730
 
@@ -741,7 +772,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
741
772
  assertGlobalRulesAck(globalRulesAckKey);
742
773
  const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
743
774
  if (!tableData) {
744
- return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
775
+ throw new Error(`Table with ID ${tableId} not found.`);
745
776
  }
746
777
 
747
778
  const existingColumns = getPatchableColumns(tableData.columns);
@@ -771,9 +802,12 @@ export function registerTableTools(server, ENFYRA_API_URL) {
771
802
  columnId,
772
803
  });
773
804
 
774
- return {
775
- content: [{ type: 'text', text: `Column ${columnId} updated on table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
776
- };
805
+ return jsonContent({
806
+ action: 'column_updated',
807
+ tableId,
808
+ columnId,
809
+ result,
810
+ });
777
811
  })
778
812
  );
779
813
 
@@ -0,0 +1,581 @@
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: ['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: 'ensure_page_extension, ensure_global_extension, or ensure_widget_extension',
77
+ reason: '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
+ 'Choose count only when the source already owns an exact count; choose dot/chip for new-attention signals.',
92
+ 'Validate extension code or use an ensure_*_extension tool that validates before saving.',
93
+ ],
94
+ },
95
+ {
96
+ key: 'schema',
97
+ title: 'Table, column, relation, validation, or schema metadata change',
98
+ useWhen: [
99
+ 'Creating or changing tables, columns, relations, indexes, or validation metadata.',
100
+ 'Designing relation direction or inverse relation exposure.',
101
+ 'Changing GraphQL/table schema flags alongside metadata.',
102
+ ],
103
+ keywords: ['schema', 'table', 'column', 'relation', 'field', 'index', 'validation', 'inverse'],
104
+ firstTools: ['get_enfyra_required_knowledge', 'inspect_table', 'get_all_tables'],
105
+ inspectTools: ['inspect_table', 'get_table_metadata', 'get_all_tables'],
106
+ knowledgeTools: ['get_enfyra_required_knowledge', 'get_enfyra_examples'],
107
+ writeTools: ['create_table', 'update_table', 'delete_table', 'create_column', 'update_column', 'delete_column', 'create_relation', 'delete_relation', 'ensure_column_rule'],
108
+ verifyTools: ['inspect_table', 'get_table_metadata'],
109
+ avoidTools: [
110
+ {
111
+ tool: 'create_record/update_record on enfyra_column or enfyra_relation',
112
+ when: 'changing schema metadata',
113
+ useInstead: 'table/column/relation schema tools',
114
+ reason: 'Schema tools resolve table ids, preserve relation contracts, and reject physical FK names.',
115
+ },
116
+ {
117
+ tool: 'manual inversePropertyName',
118
+ when: 'there is no concrete response/UI/deep-query/aggregate need for reverse traversal',
119
+ useInstead: 'owning relation only',
120
+ reason: 'Relation design stays minimal unless the reverse field is actually used.',
121
+ },
122
+ ],
123
+ requiredAck: ['globalRulesAckKey'],
124
+ exampleCategories: ['schema-relations'],
125
+ nextStepTemplate: [
126
+ 'Inspect the existing table and relations.',
127
+ 'Decide owner relation and whether an inverse is actually needed.',
128
+ 'Apply schema tool changes with globalRulesAckKey.',
129
+ 'Re-inspect metadata instead of assuming the saved shape.',
130
+ ],
131
+ },
132
+ {
133
+ key: 'record-data',
134
+ title: 'Route-backed table data query or CRUD',
135
+ useWhen: [
136
+ 'Reading or mutating normal route-backed records.',
137
+ 'Counting records or finding one row by filter.',
138
+ 'Testing filters, fields, deep relations, pagination, or aggregate query shape.',
139
+ ],
140
+ keywords: ['record', 'crud', 'query', 'count', 'filter', 'aggregate', 'deep', 'sort', 'pagination'],
141
+ firstTools: ['inspect_table', 'discover_query_capabilities'],
142
+ inspectTools: ['inspect_table', 'get_table_metadata', 'discover_query_capabilities'],
143
+ knowledgeTools: ['get_enfyra_required_knowledge for writes'],
144
+ writeTools: ['create_record', 'update_record', 'delete_record'],
145
+ verifyTools: ['find_one_record', 'query_table', 'count_records'],
146
+ avoidTools: [
147
+ {
148
+ tool: 'query_table without limit or all=true',
149
+ when: 'listing records',
150
+ useInstead: 'query_table with a bounded limit or all=true for intentional complete reads',
151
+ reason: 'List/query tools require explicit paging intent.',
152
+ },
153
+ {
154
+ tool: 'generic CRUD on internal/no-route system tables',
155
+ when: 'changing schema, sessions, columns, or other no-route internals',
156
+ useInstead: 'specific schema/platform tools',
157
+ reason: 'Generic CRUD is for route-backed tables only.',
158
+ },
159
+ ],
160
+ requiredAck: ['globalRulesAckKey for writes', 'dynamicCodeAckKey for script-backed sourceCode writes', 'extensionAckKey for extension code writes'],
161
+ exampleCategories: ['queries-deep'],
162
+ nextStepTemplate: [
163
+ 'Inspect table metadata and choose fields explicitly.',
164
+ 'Use bounded pagination or all=true deliberately.',
165
+ 'For writes, read required knowledge and use metadata-backed field names only.',
166
+ 'Re-read with explicit fields after mutation when saved shape matters.',
167
+ ],
168
+ },
169
+ {
170
+ key: 'dynamic-script',
171
+ title: 'Dynamic server code: handlers, hooks, scripts, or source patches',
172
+ useWhen: [
173
+ 'Writing or reviewing handler, hook, flow step, websocket, GraphQL, or bootstrap sourceCode.',
174
+ 'Editing an existing script-backed metadata record.',
175
+ 'Debugging macro, repository, or validation behavior.',
176
+ ],
177
+ keywords: ['sourcecode', 'script', 'hook', 'pre hook', 'post hook', 'compiledcode', 'macro', 'repos', 'bootstrap'],
178
+ firstTools: ['get_enfyra_required_knowledge', 'discover_script_contexts', 'trace_metadata_usage'],
179
+ inspectTools: ['trace_metadata_usage', 'get_script_source', 'discover_script_contexts'],
180
+ knowledgeTools: ['get_enfyra_required_knowledge', 'discover_script_contexts'],
181
+ writeTools: ['patch_script_source', 'update_script_source', 'create_handler', 'create_pre_hook', 'create_post_hook', 'api_endpoint_workflow'],
182
+ verifyTools: ['validate_dynamic_script', 'run_admin_test', 'test_rest_endpoint', 'test_flow_step'],
183
+ avoidTools: [
184
+ {
185
+ tool: 'update_record with compiledCode',
186
+ when: 'editing dynamic scripts',
187
+ useInstead: 'patch_script_source or update_script_source with sourceCode',
188
+ reason: 'compiledCode is generated and may differ because macros expand.',
189
+ },
190
+ {
191
+ tool: 'throw new Error for intentional user/domain failures',
192
+ when: 'writing generated dynamic server code',
193
+ useInstead: '@THROW400-style macros or native $ctx.$throw helpers',
194
+ reason: 'Intentional domain errors should use the platform error contract.',
195
+ },
196
+ ],
197
+ requiredAck: ['globalRulesAckKey', 'dynamicCodeAckKey'],
198
+ exampleCategories: ['handlers-hooks'],
199
+ nextStepTemplate: [
200
+ 'Discover script context macros for the surface.',
201
+ 'Read existing source through trace_metadata_usage/get_script_source when patching.',
202
+ 'Validate source before save unless the chosen write tool already validates.',
203
+ 'Verify behavior with the route/test runner that matches the script surface.',
204
+ ],
205
+ },
206
+ {
207
+ key: 'route-access',
208
+ title: 'Authenticated route access and public/private method state',
209
+ useWhen: [
210
+ 'Debugging 403/401 route access.',
211
+ 'Granting role/user access to a route method.',
212
+ 'Changing publicMethods or availableMethods.',
213
+ ],
214
+ keywords: ['permission', '403', '401', 'role', 'route access', 'public method', 'private method', 'available method'],
215
+ firstTools: ['get_permission_profile', 'inspect_route', 'audit_route_access'],
216
+ inspectTools: ['get_permission_profile', 'inspect_route', 'audit_route_access'],
217
+ knowledgeTools: ['get_enfyra_required_knowledge'],
218
+ writeTools: ['ensure_route_access', 'add_route_methods', 'replace_route_methods', 'remove_route_methods', 'public_route_methods', 'private_route_methods', 'enable_route', 'disable_route'],
219
+ verifyTools: ['audit_route_access', 'test_rest_endpoint'],
220
+ avoidTools: [
221
+ {
222
+ tool: 'raw enfyra_route_permission CRUD',
223
+ when: 'granting route access',
224
+ useInstead: 'ensure_route_access',
225
+ reason: 'The operation tool resolves roles/methods and merges existing method grants safely.',
226
+ },
227
+ {
228
+ tool: 'public_route_methods',
229
+ when: 'the desired behavior is authenticated user access',
230
+ useInstead: 'ensure_route_access',
231
+ reason: 'publicMethods grants anonymous access; route permissions grant authenticated access.',
232
+ },
233
+ ],
234
+ requiredAck: ['globalRulesAckKey for writes'],
235
+ exampleCategories: ['permissions-rls'],
236
+ nextStepTemplate: [
237
+ 'Inspect route and permission profile before changing access.',
238
+ 'Decide anonymous publicMethods versus authenticated route permission.',
239
+ 'Use route operation tools instead of raw permission CRUD.',
240
+ 'Audit and test the route after the change.',
241
+ ],
242
+ },
243
+ {
244
+ key: 'guards-permissions-rules',
245
+ title: 'Guards, field permissions, and column validation rules',
246
+ useWhen: [
247
+ 'Adding route guards, guard rules, field permissions, or column rules.',
248
+ 'Restricting field read/write behavior.',
249
+ 'Adding body validation rules at metadata level.',
250
+ ],
251
+ keywords: ['guard', 'field permission', 'column rule', 'validation rule', 'rule', 'rls'],
252
+ firstTools: ['get_enfyra_required_knowledge', 'inspect_table', 'inspect_route'],
253
+ inspectTools: ['inspect_table', 'inspect_route', 'discover_query_capabilities'],
254
+ knowledgeTools: ['get_enfyra_required_knowledge'],
255
+ writeTools: ['ensure_guard', 'ensure_field_permission', 'ensure_column_rule'],
256
+ verifyTools: ['test_rest_endpoint', 'query_table', 'run_admin_test'],
257
+ avoidTools: [
258
+ {
259
+ tool: 'raw create_record on guard/rule tables',
260
+ when: 'a dedicated ensure_* operation exists',
261
+ useInstead: 'ensure_guard, ensure_field_permission, or ensure_column_rule',
262
+ reason: 'Ensure tools resolve ids and preserve the current rule contract.',
263
+ },
264
+ ],
265
+ requiredAck: ['globalRulesAckKey'],
266
+ exampleCategories: ['permissions-rls', 'schema-relations'],
267
+ nextStepTemplate: [
268
+ 'Inspect the target table/route and decide the security boundary first.',
269
+ 'Use the specific ensure_* operation for the rule surface.',
270
+ 'Verify with the route/query behavior the rule is meant to protect.',
271
+ ],
272
+ },
273
+ {
274
+ key: 'flow',
275
+ title: 'Flow, scheduled/manual flow, or flow step',
276
+ useWhen: [
277
+ 'Creating or changing manual/scheduled flows.',
278
+ 'Choosing or writing a flow step.',
279
+ 'Testing or triggering a flow.',
280
+ ],
281
+ keywords: ['flow', 'scheduled', 'manual flow', 'flow step', 'trigger flow', 'workflow'],
282
+ firstTools: ['get_enfyra_required_knowledge', 'choose_flow_step_tool', 'discover_script_contexts'],
283
+ inspectTools: ['inspect_feature', 'query_table'],
284
+ knowledgeTools: ['get_enfyra_required_knowledge', 'discover_script_contexts'],
285
+ 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'],
286
+ verifyTools: ['test_flow_step', 'run_admin_test', 'trigger_flow'],
287
+ avoidTools: [
288
+ {
289
+ tool: 'ensure_script_flow_step',
290
+ when: 'a fixed query/create/update/delete/http/sleep/trigger/log step can express the operation',
291
+ useInstead: 'choose_flow_step_tool then the fixed-type ensure_*_flow_step',
292
+ reason: 'Atomic step types are easier to inspect, test, and maintain than oversized scripts.',
293
+ },
294
+ ],
295
+ requiredAck: ['globalRulesAckKey', 'dynamicCodeAckKey for script or condition source'],
296
+ exampleCategories: ['flows'],
297
+ nextStepTemplate: [
298
+ 'Use choose_flow_step_tool before mutating when step type is unclear.',
299
+ 'Prefer fixed-type flow step tools over script steps.',
300
+ 'Validate/test script or condition steps before relying on the flow.',
301
+ 'Trigger manually only after the saved steps are verified.',
302
+ ],
303
+ },
304
+ {
305
+ key: 'websocket',
306
+ title: 'Socket.IO gateway or websocket event',
307
+ useWhen: [
308
+ 'Creating or changing websocket gateways/events.',
309
+ 'Writing websocket handler source.',
310
+ 'Testing websocket event logic.',
311
+ ],
312
+ keywords: ['websocket', 'socket', 'socket.io', 'gateway', 'realtime', 'room'],
313
+ firstTools: ['get_enfyra_required_knowledge', 'discover_script_contexts', 'inspect_feature'],
314
+ inspectTools: ['inspect_feature', 'discover_script_contexts'],
315
+ knowledgeTools: ['get_enfyra_required_knowledge', 'discover_script_contexts'],
316
+ writeTools: ['ensure_websocket_gateway', 'ensure_websocket_event'],
317
+ verifyTools: ['run_admin_test'],
318
+ avoidTools: [
319
+ {
320
+ tool: 'raw CRUD on enfyra_websocket_event',
321
+ when: 'saving websocket event source',
322
+ useInstead: 'ensure_websocket_event',
323
+ reason: 'The operation tool validates script source and preserves gateway/event linkage.',
324
+ },
325
+ ],
326
+ requiredAck: ['globalRulesAckKey', 'dynamicCodeAckKey when saving event source'],
327
+ exampleCategories: ['websocket'],
328
+ nextStepTemplate: [
329
+ 'Discover websocket script context before writing source.',
330
+ 'Ensure gateway first, then event.',
331
+ 'Use run_admin_test for event/connection scripts where possible.',
332
+ ],
333
+ },
334
+ {
335
+ key: 'graphql',
336
+ title: 'GraphQL enablement and query surface',
337
+ useWhen: [
338
+ 'Enabling/disabling GraphQL for a table.',
339
+ 'Checking GraphQL endpoint/schema behavior.',
340
+ 'Clarifying REST route methods versus GraphQL table exposure.',
341
+ ],
342
+ keywords: ['graphql', 'gql', 'schema endpoint'],
343
+ firstTools: ['discover_enfyra_system', 'inspect_table'],
344
+ inspectTools: ['discover_enfyra_system', 'inspect_table'],
345
+ knowledgeTools: ['get_enfyra_required_knowledge for writes'],
346
+ writeTools: ['set_table_graphql', 'update_table'],
347
+ verifyTools: ['reload_graphql', 'discover_enfyra_system'],
348
+ avoidTools: [
349
+ {
350
+ tool: 'public_route_methods',
351
+ when: 'trying to expose GraphQL table data',
352
+ useInstead: 'set_table_graphql plus GraphQL auth planning',
353
+ reason: 'REST publicMethods do not make GraphQL table data anonymous.',
354
+ },
355
+ ],
356
+ requiredAck: ['globalRulesAckKey for writes'],
357
+ exampleCategories: ['queries-deep'],
358
+ nextStepTemplate: [
359
+ 'Inspect the table and GraphQL enablement state.',
360
+ 'Use set_table_graphql for enablement changes.',
361
+ 'Remember GraphQL table data requires Bearer auth even when REST is public.',
362
+ ],
363
+ },
364
+ {
365
+ key: 'package',
366
+ title: 'Runtime package install or package-backed extension/script support',
367
+ useWhen: [
368
+ 'Installing npm packages for dynamic code or extension runtime use.',
369
+ 'Checking package availability before using getPackages in extensions.',
370
+ ],
371
+ keywords: ['package', 'npm', 'install', 'dependency', 'getpackages'],
372
+ firstTools: ['search_npm', 'get_enfyra_required_knowledge'],
373
+ inspectTools: ['search_npm', 'query_table'],
374
+ knowledgeTools: ['get_enfyra_required_knowledge'],
375
+ writeTools: ['install_package'],
376
+ verifyTools: ['query_table'],
377
+ avoidTools: [
378
+ {
379
+ tool: 'raw create_record on enfyra_package',
380
+ when: 'installing packages',
381
+ useInstead: 'install_package',
382
+ reason: 'The package tool resolves package metadata and avoids duplicate package records.',
383
+ },
384
+ ],
385
+ requiredAck: ['globalRulesAckKey'],
386
+ exampleCategories: ['extensions', 'handlers-hooks'],
387
+ nextStepTemplate: [
388
+ 'Search package metadata first.',
389
+ 'Install with install_package and globalRulesAckKey.',
390
+ 'Use getPackages inside extension runtime code rather than static imports.',
391
+ ],
392
+ },
393
+ {
394
+ key: 'cache',
395
+ title: 'Cache reload or stale metadata/runtime diagnosis',
396
+ useWhen: [
397
+ 'Diagnosing stale routes, metadata, GraphQL, or full runtime cache.',
398
+ 'Manually reloading only after natural partial reload appears stale.',
399
+ ],
400
+ keywords: ['cache', 'reload', 'stale', 'refresh metadata', 'reload routes'],
401
+ firstTools: ['inspect_table', 'inspect_route', 'get_enfyra_api_context'],
402
+ inspectTools: ['inspect_table', 'inspect_route', 'get_enfyra_api_context'],
403
+ knowledgeTools: ['get_enfyra_required_knowledge for manual reloads'],
404
+ writeTools: ['reload_metadata', 'reload_routes', 'reload_graphql', 'reload_all'],
405
+ verifyTools: ['inspect_table', 'inspect_route', 'discover_enfyra_system'],
406
+ avoidTools: [
407
+ {
408
+ tool: 'manual reload tools',
409
+ when: 'a successful metadata mutation already triggered natural partial reload and no stale evidence exists',
410
+ useInstead: 'verify behavior first with inspect/test tools',
411
+ reason: 'Manual reloads should be evidence-driven, not reflexive.',
412
+ },
413
+ ],
414
+ requiredAck: ['globalRulesAckKey for manual reload tools'],
415
+ exampleCategories: [],
416
+ nextStepTemplate: [
417
+ 'Verify stale behavior with narrow inspect/test tools.',
418
+ 'Choose the narrowest reload surface if stale evidence exists.',
419
+ 'Re-verify the same narrow behavior after reload.',
420
+ ],
421
+ },
422
+ {
423
+ key: 'logs-debug',
424
+ title: 'Logs, runtime diagnostics, and test runner debugging',
425
+ useWhen: [
426
+ 'Reading app/error logs.',
427
+ 'Searching logs for route, flow, websocket, or dynamic script failures.',
428
+ 'Running admin tests for supported runtime surfaces.',
429
+ ],
430
+ keywords: ['log', 'debug', 'error', 'trace', 'tail', 'diagnostic', 'test runner'],
431
+ firstTools: ['get_log_files', 'search_logs'],
432
+ inspectTools: ['get_log_files', 'get_log_content', 'tail_log', 'search_logs'],
433
+ knowledgeTools: [],
434
+ writeTools: [],
435
+ verifyTools: ['run_admin_test', 'test_flow_step', 'test_rest_endpoint'],
436
+ avoidTools: [
437
+ {
438
+ tool: 'broad metadata discovery',
439
+ when: 'the problem is a concrete runtime error with known log text',
440
+ useInstead: 'search_logs or tail_log',
441
+ reason: 'Log tools are narrower and cheaper for runtime debugging.',
442
+ },
443
+ ],
444
+ requiredAck: [],
445
+ exampleCategories: [],
446
+ nextStepTemplate: [
447
+ 'Search or tail the narrowest log first.',
448
+ 'Use the matching test tool to reproduce once the failing surface is known.',
449
+ 'Patch only after the failing step is identified.',
450
+ ],
451
+ },
452
+ {
453
+ key: 'auth-context',
454
+ title: 'MCP target, auth token, current user, and permission profile',
455
+ useWhen: [
456
+ 'Confirming which Enfyra instance MCP is connected to.',
457
+ 'Debugging API-token exchange or current MCP user permissions.',
458
+ 'Checking whether non-root tokens can call admin helper routes.',
459
+ ],
460
+ keywords: ['auth', 'token', 'current user', 'permission profile', 'target', 'api base', '403', 'exchange'],
461
+ firstTools: ['get_enfyra_api_context', 'get_current_user', 'get_permission_profile'],
462
+ inspectTools: ['get_enfyra_api_context', 'get_current_user', 'get_permission_profile'],
463
+ knowledgeTools: [],
464
+ writeTools: ['login'],
465
+ verifyTools: ['get_current_user', 'get_permission_profile'],
466
+ avoidTools: [
467
+ {
468
+ tool: 'discover_enfyra_system',
469
+ when: 'only confirming the connected API base',
470
+ useInstead: 'get_enfyra_api_context',
471
+ reason: 'Target sanity checks should be cheap and should not load broad metadata.',
472
+ },
473
+ ],
474
+ requiredAck: [],
475
+ exampleCategories: ['ssr-app-auth', 'oauth-setup'],
476
+ nextStepTemplate: [
477
+ 'Use get_enfyra_api_context for target sanity checks.',
478
+ 'Use get_permission_profile before assuming admin helper route access with non-root tokens.',
479
+ 'Use login only when an interactive credential login is explicitly needed.',
480
+ ],
481
+ },
482
+ ];
483
+
484
+ function compactWorkflow(workflow) {
485
+ return {
486
+ key: workflow.key,
487
+ title: workflow.title,
488
+ useWhen: workflow.useWhen,
489
+ };
490
+ }
491
+
492
+ function planWorkflow(workflow) {
493
+ return {
494
+ ...compactWorkflow(workflow),
495
+ firstTools: workflow.firstTools,
496
+ inspectTools: workflow.inspectTools,
497
+ knowledgeTools: workflow.knowledgeTools,
498
+ writeTools: workflow.writeTools,
499
+ verifyTools: workflow.verifyTools,
500
+ requiredAck: workflow.requiredAck,
501
+ exampleCategories: workflow.exampleCategories,
502
+ nextSteps: workflow.nextStepTemplate,
503
+ avoidTools: workflow.avoidTools,
504
+ };
505
+ }
506
+
507
+ function fullWorkflow(workflow) {
508
+ return {
509
+ ...planWorkflow(workflow),
510
+ keywords: workflow.keywords,
511
+ };
512
+ }
513
+
514
+ function normalize(value) {
515
+ return String(value || '').trim().toLowerCase();
516
+ }
517
+
518
+ function scoreWorkflow(workflow, { intent, surface, risk }) {
519
+ let score = 0;
520
+ const text = normalize(intent);
521
+ if (surface && workflow.key === surface) score += 20;
522
+ if (surface && workflow.key.includes(surface)) score += 8;
523
+ if (text) {
524
+ for (const keyword of workflow.keywords || []) {
525
+ if (text.includes(keyword)) score += 4;
526
+ }
527
+ for (const phrase of workflow.useWhen || []) {
528
+ const words = normalize(phrase).split(/\W+/).filter((word) => word.length > 3);
529
+ if (words.some((word) => text.includes(word))) score += 1;
530
+ }
531
+ }
532
+ if (risk === 'debug' && workflow.key === 'logs-debug') score += 6;
533
+ if (risk === 'write' && workflow.writeTools.length) score += 2;
534
+ if (risk === 'destructive' && workflow.avoidTools.some((item) => normalize(item.when).includes('delete'))) score += 2;
535
+ return score;
536
+ }
537
+
538
+ export function listWorkflowSurfaces() {
539
+ return TOOL_WORKFLOWS.map(compactWorkflow);
540
+ }
541
+
542
+ export function discoverWorkflowRoutes({
543
+ intent = '',
544
+ surface,
545
+ risk = 'unknown',
546
+ detail = 'summary',
547
+ limit = 5,
548
+ } = {}) {
549
+ const normalizedSurface = surface ? normalize(surface) : undefined;
550
+ const normalizedDetail = ALL_DETAILS.includes(detail) ? detail : 'summary';
551
+ const normalizedRisk = normalize(risk) || 'unknown';
552
+ const formatter = normalizedDetail === 'full'
553
+ ? fullWorkflow
554
+ : normalizedDetail === 'plan'
555
+ ? planWorkflow
556
+ : compactWorkflow;
557
+ const scored = TOOL_WORKFLOWS
558
+ .map((workflow) => ({ workflow, score: scoreWorkflow(workflow, { intent, surface: normalizedSurface, risk: normalizedRisk }) }))
559
+ .filter((item) => !normalizedSurface || item.workflow.key === normalizedSurface || item.score > 0)
560
+ .sort((a, b) => b.score - a.score || a.workflow.key.localeCompare(b.workflow.key));
561
+ const selected = (scored.length ? scored : TOOL_WORKFLOWS.map((workflow) => ({ workflow, score: 0 })))
562
+ .slice(0, Math.max(1, Math.min(Number(limit) || 5, 10)));
563
+ return {
564
+ action: 'enfyra_workflows_discovered',
565
+ intent: intent || null,
566
+ requestedSurface: surface || null,
567
+ risk: normalizedRisk,
568
+ detail: normalizedDetail,
569
+ matchedWorkflowCount: scored.length,
570
+ workflows: selected.map((item) => ({
571
+ score: item.score,
572
+ ...formatter(item.workflow),
573
+ })),
574
+ surfaces: normalizedDetail === 'summary' ? WORKFLOW_SURFACES : undefined,
575
+ guidance: [
576
+ 'Use this as progressive disclosure: pick the closest workflow, then call its firstTools instead of loading every Enfyra tool/example.',
577
+ 'For writes, call get_enfyra_required_knowledge and pass the returned acknowledgement keys into write tools.',
578
+ 'Treat avoidTools as negative routing boundaries; they prevent near-correct tool choices from crossing the wrong platform contract.',
579
+ ],
580
+ };
581
+ }
@@ -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';
@@ -331,7 +332,7 @@ function summarizeRoutes(routesResult) {
331
332
  }));
332
333
  }
333
334
 
334
- function summarizeMetadata(metadata, { search, limit } = {}) {
335
+ function summarizeMetadata(metadata, { search, limit, all = false } = {}) {
335
336
  const tables = normalizeTables(metadata);
336
337
  const q = search ? search.toLowerCase() : null;
337
338
  const summarized = tables.map((table) => ({
@@ -346,11 +347,13 @@ function summarizeMetadata(metadata, { search, limit } = {}) {
346
347
  const matched = q
347
348
  ? summarized.filter((table) => JSON.stringify(table).toLowerCase().includes(q))
348
349
  : summarized;
349
- const outputLimit = limit || 30;
350
+ const outputLimit = all ? matched.length : (limit || 30);
350
351
  return {
351
352
  tableCount: tables.length,
352
353
  matchedTableCount: matched.length,
353
354
  returnedTableCount: Math.min(matched.length, outputLimit),
355
+ complete: all || outputLimit >= matched.length,
356
+ hardCap: all ? null : outputLimit,
354
357
  search: search || null,
355
358
  tables: matched.slice(0, outputLimit),
356
359
  };
@@ -807,15 +810,21 @@ server.tool('get_all_metadata', 'Get concise metadata summary for all tables. Us
807
810
  includeFull: z.boolean().optional().default(false).describe('Return full raw metadata. Default false to keep MCP context small.'),
808
811
  search: z.string().optional().describe('Optional table-name/alias substring filter.'),
809
812
  limit: z.number().optional().describe('Maximum tables returned after search. Default 30.'),
810
- }, async ({ includeFull, search, limit }) => {
813
+ all: z.boolean().optional().default(false).describe('Return every matched table summary. Use when a complete table list is required.'),
814
+ }, async ({ includeFull, search, limit, all }) => {
815
+ if (all && limit !== undefined) {
816
+ throw new Error('get_all_metadata accepts either all=true or limit, not both.');
817
+ }
811
818
  const result = await fetchAPI(ENFYRA_API_URL, '/metadata');
812
819
  const payload = includeFull
813
820
  ? result
814
821
  : {
815
822
  statusCode: result?.statusCode,
816
823
  success: result?.success,
817
- ...summarizeMetadata(result, { search, limit }),
818
- detailHint: 'Default response is capped and minimal. Call get_table_metadata({ tableName }) or inspect_table({ tableName }) for columns, relations, and route context.',
824
+ ...summarizeMetadata(result, { search, limit, all }),
825
+ detailHint: all
826
+ ? 'Complete summary returned. Call get_table_metadata({ tableName }) or inspect_table({ tableName }) for columns, relations, and route context.'
827
+ : 'Default response is capped and minimal. Pass all=true for a complete summary, or call get_table_metadata({ tableName }) / inspect_table({ tableName }) for detail.',
819
828
  };
820
829
  return jsonContent(payload);
821
830
  });
@@ -852,6 +861,23 @@ server.tool(
852
861
  },
853
862
  );
854
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
+
855
881
  server.tool(
856
882
  'discover_enfyra_system',
857
883
  [
@@ -2109,16 +2135,11 @@ server.tool(
2109
2135
  tableName: z.string().describe('Table name or alias to inspect'),
2110
2136
  },
2111
2137
  async ({ tableName }) => {
2112
- let state = await collectRestDefinitionState();
2113
- let table = state.tables.find((item) => item?.name === tableName || item?.alias === tableName);
2138
+ const state = await collectRestDefinitionState();
2139
+ const table = state.tables.find((item) => item?.name === tableName || item?.alias === tableName);
2114
2140
  if (!table) {
2115
- await fetchAPI(ENFYRA_API_URL, '/admin/reload/metadata', { method: 'POST' }).catch(() => {});
2116
- await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
2117
- await new Promise((resolve) => setTimeout(resolve, 150));
2118
- state = await collectRestDefinitionState();
2119
- table = state.tables.find((item) => item?.name === tableName || item?.alias === tableName);
2141
+ 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.`);
2120
2142
  }
2121
- if (!table) throw new Error(`Unknown table "${tableName}"`);
2122
2143
  const tableId = getId(table);
2123
2144
  const columnIds = new Set((table.columns || []).map((column) => String(getId(column))));
2124
2145
  const relationIds = new Set((table.relations || []).map((relation) => String(getId(relation))));
@@ -2420,7 +2441,7 @@ server.tool('get_all_routes', 'List route definitions with minimal fields. Every
2420
2441
  const queryParams = new URLSearchParams({
2421
2442
  filter: JSON.stringify(filter),
2422
2443
  fields: 'id,path,mainTable.name,availableMethods.*,publicMethods.*,isEnabled',
2423
- limit: '1000',
2444
+ limit: all ? '0' : '1000',
2424
2445
  });
2425
2446
  const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_route?${queryParams.toString()}`);
2426
2447
  const q = search ? search.toLowerCase() : null;
@@ -2439,6 +2460,8 @@ server.tool('get_all_routes', 'List route definitions with minimal fields. Every
2439
2460
  matchedRouteCount: matchedRoutes.length,
2440
2461
  returnedRouteCount: Math.min(matchedRoutes.length, routeLimit),
2441
2462
  all: !!all,
2463
+ complete: all || routeLimit >= matchedRoutes.length,
2464
+ hardCap: all ? null : routeLimit,
2442
2465
  search: search || null,
2443
2466
  routes: matchedRoutes.slice(0, routeLimit),
2444
2467
  detailHint: matchedRoutes.length > routeLimit
@@ -2885,7 +2908,7 @@ server.tool('reload_all', 'Reload all caches (metadata, routes, GraphQL)', {
2885
2908
  }, async ({ globalRulesAckKey }) => {
2886
2909
  assertGlobalRulesAck(globalRulesAckKey);
2887
2910
  const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload', { method: 'POST' });
2888
- return { content: [{ type: 'text', text: `System reloaded:\n${JSON.stringify(result, null, 2)}` }] };
2911
+ return jsonContent({ action: 'reloaded_all', result });
2889
2912
  });
2890
2913
 
2891
2914
  server.tool('reload_metadata', 'Reload metadata cache only', {
@@ -2893,7 +2916,7 @@ server.tool('reload_metadata', 'Reload metadata cache only', {
2893
2916
  }, async ({ globalRulesAckKey }) => {
2894
2917
  assertGlobalRulesAck(globalRulesAckKey);
2895
2918
  const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/metadata', { method: 'POST' });
2896
- return { content: [{ type: 'text', text: `Metadata reloaded:\n${JSON.stringify(result, null, 2)}` }] };
2919
+ return jsonContent({ action: 'reloaded_metadata', result });
2897
2920
  });
2898
2921
 
2899
2922
  server.tool('reload_routes', 'Reload routes cache only', {
@@ -2901,7 +2924,7 @@ server.tool('reload_routes', 'Reload routes cache only', {
2901
2924
  }, async ({ globalRulesAckKey }) => {
2902
2925
  assertGlobalRulesAck(globalRulesAckKey);
2903
2926
  const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' });
2904
- return { content: [{ type: 'text', text: `Routes reloaded:\n${JSON.stringify(result, null, 2)}` }] };
2927
+ return jsonContent({ action: 'reloaded_routes', result });
2905
2928
  });
2906
2929
 
2907
2930
  server.tool('reload_graphql', 'Reload GraphQL schema', {
@@ -2909,7 +2932,7 @@ server.tool('reload_graphql', 'Reload GraphQL schema', {
2909
2932
  }, async ({ globalRulesAckKey }) => {
2910
2933
  assertGlobalRulesAck(globalRulesAckKey);
2911
2934
  const result = await fetchAPI(ENFYRA_API_URL, '/admin/reload/graphql', { method: 'POST' });
2912
- return { content: [{ type: 'text', text: `GraphQL reloaded:\n${JSON.stringify(result, null, 2)}` }] };
2935
+ return jsonContent({ action: 'reloaded_graphql', result });
2913
2936
  });
2914
2937
 
2915
2938
  // ============================================================================
@@ -3080,12 +3103,15 @@ server.tool(
3080
3103
  const checkFilter = JSON.stringify({ name: { _eq: name }, type: { _eq: type } });
3081
3104
  const existing = await fetchAPI(ENFYRA_API_URL, `/enfyra_package?filter=${encodeURIComponent(checkFilter)}&limit=1`);
3082
3105
  if (existing.data && existing.data.length > 0) {
3083
- return {
3084
- content: [{
3085
- type: 'text',
3086
- text: `Package "${name}" is already installed (version: ${existing.data[0].version}, type: ${existing.data[0].type}).\n${JSON.stringify(existing.data[0], null, 2)}`,
3087
- }],
3088
- };
3106
+ return jsonContent({
3107
+ action: 'package_already_installed',
3108
+ package: {
3109
+ name,
3110
+ version: existing.data[0].version,
3111
+ type: existing.data[0].type,
3112
+ },
3113
+ record: existing.data[0],
3114
+ });
3089
3115
  }
3090
3116
 
3091
3117
  // Step 3: Get current user for installedBy
@@ -3107,12 +3133,11 @@ server.tool(
3107
3133
  body: JSON.stringify(body),
3108
3134
  });
3109
3135
 
3110
- return {
3111
- content: [{
3112
- type: 'text',
3113
- text: `Package "${name}@${pkgVersion}" installed successfully (type: ${type}).\n${JSON.stringify(result, null, 2)}`,
3114
- }],
3115
- };
3136
+ return jsonContent({
3137
+ action: 'package_installed',
3138
+ package: { name, version: pkgVersion, type },
3139
+ result,
3140
+ });
3116
3141
  },
3117
3142
  );
3118
3143