@enfyra/mcp-server 0.0.108 → 0.0.110

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
@@ -191,7 +191,7 @@ The MCP server includes safety guards for LLM callers:
191
191
  - Relation tools reject physical FK/junction names and resolve table ids from exact table names or aliases before schema mutation.
192
192
  - Generated code should use relation property names such as `conversation`, `sender`, and `member` instead of physical FK fields such as `conversationId`, `senderId`, or `memberId`.
193
193
  - Custom route tools reject `mainTableId` unless the route is the canonical table route.
194
- - 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`, `ensure_script_flow_step`, `ensure_menu`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension` resolve metadata ids and validate code before saving.
194
+ - 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.
195
195
  - Schema changes are serialized.
196
196
  - Destructive deletes return a preview before requiring `confirm=true`.
197
197
 
@@ -233,8 +233,12 @@ The MCP server exposes tools for metadata discovery, examples, query/CRUD, metho
233
233
 
234
234
  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.
235
235
 
236
+ Admin app page paths and API paths are different surfaces. A page extension path such as `/cloud/projects/:id` is a UI route unless an enabled Enfyra API route with that exact path exists. Use `test_rest_endpoint` only for actual API routes under `ENFYRA_API_URL`; verify page extensions through the app URL/browser or extension/menu metadata.
237
+
236
238
  For authenticated route access, use `audit_route_access` before changing permissions and `ensure_route_access` to grant access by route path plus role/user. For production script edits, use `trace_metadata_usage`, `get_script_source`, and `patch_script_source` so changes are targeted, hash-checked, and validated.
237
239
 
238
240
  ## Security
239
241
 
242
+ Treat permission and security as the first step for every change: decide public/private methods, authenticated route access, owner/tenant scope, and field exposure before creating handlers, flows, extensions, or UI.
243
+
240
244
  API calls use exchanged JWTs and Enfyra permissions are still enforced server-side. Keep `ENFYRA_API_TOKEN` out of committed config unless the project intentionally uses environment interpolation or another secret-management path.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.108",
3
+ "version": "0.0.110",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/lib/auth.js CHANGED
@@ -8,13 +8,25 @@ let accessToken = null;
8
8
  let refreshToken = null;
9
9
  let tokenExpiry = null; // expTime từ server (milliseconds)
10
10
  let isRefreshing = false;
11
+ let exchangePromise = null;
11
12
 
12
13
  // Config
13
14
  let API_URL = 'http://localhost:3000/api';
14
15
  let API_TOKEN = '';
15
16
 
16
- // Refresh buffer: refresh token 1 minute before expiry
17
- const TOKEN_REFRESH_BUFFER = 60000;
17
+ const TOKEN_REFRESH_BUFFER = 20000;
18
+
19
+ function normalizeExpiry(expTime) {
20
+ if (expTime == null) return Infinity;
21
+ if (typeof expTime === 'number') return expTime < 1_000_000_000_000 ? expTime * 1000 : expTime;
22
+ if (typeof expTime === 'string' && expTime.trim()) {
23
+ const numeric = Number(expTime);
24
+ if (Number.isFinite(numeric)) return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric;
25
+ const parsed = Date.parse(expTime);
26
+ if (Number.isFinite(parsed)) return parsed;
27
+ }
28
+ return Infinity;
29
+ }
18
30
 
19
31
  /**
20
32
  * Initialize auth module with config
@@ -25,7 +37,7 @@ export function initAuth(apiUrl, apiToken = '') {
25
37
  }
26
38
 
27
39
  /**
28
- * Check if token needs refresh (expires within 1 minute)
40
+ * Check if token needs refresh (expires within the refresh buffer)
29
41
  */
30
42
  export function needsRefresh() {
31
43
  if (tokenExpiry === Infinity) return false;
@@ -60,27 +72,37 @@ export async function exchangeApiToken(url, apiToken) {
60
72
  throw new Error('API token required');
61
73
  }
62
74
 
63
- console.error('[Auth] Exchanging API token...');
64
- const response = await fetch(`${apiUrl}/auth/token/exchange`, {
65
- method: 'POST',
66
- headers: { 'Content-Type': 'application/json' },
67
- body: JSON.stringify({ apiToken: token }),
68
- });
75
+ if (exchangePromise) return exchangePromise;
69
76
 
70
- if (!response.ok) {
71
- throw new Error(`API token exchange failed: ${await response.text()}`);
72
- }
77
+ exchangePromise = (async () => {
78
+ console.error('[Auth] Exchanging API token...');
79
+ const response = await fetch(`${apiUrl}/auth/token/exchange`, {
80
+ method: 'POST',
81
+ headers: { 'Content-Type': 'application/json' },
82
+ body: JSON.stringify({ apiToken: token }),
83
+ });
73
84
 
74
- const data = await response.json();
75
- accessToken = data.accessToken || data.access_token;
76
- refreshToken = null;
77
- tokenExpiry = data.expTime == null ? Infinity : data.expTime;
85
+ if (!response.ok) {
86
+ throw new Error(`API token exchange failed: ${await response.text()}`);
87
+ }
78
88
 
79
- const expiryLabel = tokenExpiry === Infinity
80
- ? 'no expiration'
81
- : new Date(tokenExpiry).toISOString();
82
- console.error(`[Auth] API token exchanged, access token expires at ${expiryLabel}`);
83
- return accessToken;
89
+ const data = await response.json();
90
+ accessToken = data.accessToken || data.access_token;
91
+ refreshToken = null;
92
+ tokenExpiry = normalizeExpiry(data.expTime ?? data.exp_time ?? data.expiresAt ?? data.expires_at);
93
+
94
+ const expiryLabel = tokenExpiry === Infinity
95
+ ? 'no expiration'
96
+ : new Date(tokenExpiry).toISOString();
97
+ console.error(`[Auth] API token exchanged, access token expires at ${expiryLabel}`);
98
+ return accessToken;
99
+ })();
100
+
101
+ try {
102
+ return await exchangePromise;
103
+ } finally {
104
+ exchangePromise = null;
105
+ }
84
106
  }
85
107
 
86
108
  /**
@@ -119,7 +141,7 @@ export async function refreshAccessToken(url) {
119
141
  const data = await response.json();
120
142
  accessToken = data.accessToken || data.access_token;
121
143
  refreshToken = data.refreshToken || data.refresh_token;
122
- tokenExpiry = data.expTime;
144
+ tokenExpiry = normalizeExpiry(data.expTime ?? data.exp_time ?? data.expiresAt ?? data.expires_at);
123
145
 
124
146
  console.error(`[Auth] Token refreshed, expires at ${new Date(tokenExpiry).toISOString()}`);
125
147
  return accessToken;
@@ -1067,6 +1067,7 @@ ensure_route_access({
1067
1067
  description: "Authenticated users can list and create their own orders."
1068
1068
  })`,
1069
1069
  notes: [
1070
+ 'Start with the security boundary: choose public/private methods, role or user route access, owner/tenant scope, and field exposure before writing handler or UI logic.',
1070
1071
  'Use route permissions for authenticated access. The tool resolves role and method ids, validates the route available methods, merges existing methods, and reloads routes.',
1071
1072
  'Handlers or pre-hooks must still enforce owner or tenant scope; route permission only lets the request pass RoleGuard.',
1072
1073
  'Use publicMethods only for anonymous public access.',
@@ -1391,17 +1392,18 @@ return order && order.total > 1000`,
1391
1392
  {
1392
1393
  name: 'Split a provisioning workflow into focused steps',
1393
1394
  code: `[
1394
- { "key": "load_project", "stepOrder": 10, "type": "script" },
1395
+ { "key": "load_project", "stepOrder": 10, "type": "query" },
1395
1396
  { "key": "reserve_capacity", "stepOrder": 20, "type": "script" },
1396
- { "key": "create_database_user", "stepOrder": 30, "type": "script" },
1397
+ { "key": "create_database_user", "stepOrder": 30, "type": "http" },
1397
1398
  { "key": "apply_database_guardrails", "stepOrder": 40, "type": "script" },
1398
- { "key": "start_container", "stepOrder": 50, "type": "script" },
1399
- { "key": "apply_container_guardrails", "stepOrder": 60, "type": "script" },
1400
- { "key": "check_health", "stepOrder": 70, "type": "script" },
1401
- { "key": "finalize_project", "stepOrder": 80, "type": "script" }
1399
+ { "key": "start_container", "stepOrder": 50, "type": "http" },
1400
+ { "key": "check_health", "stepOrder": 60, "type": "http" },
1401
+ { "key": "finalize_project", "stepOrder": 70, "type": "update" },
1402
+ { "key": "write_audit_log", "stepOrder": 80, "type": "log" }
1402
1403
  ]`,
1403
1404
  notes: [
1404
- 'Prefer operation-sized flow steps with clear keys over one large script that performs SSH, Docker, DB, API, email, and finalization work together.',
1405
+ 'Prefer the fixed-type flow step tool that matches each operation before falling back to script.',
1406
+ 'Use choose_flow_step_tool when the right step type is unclear.',
1405
1407
  'Each step should return only ids, booleans, status keys, or small counters that later steps need.',
1406
1408
  'When refactoring an existing flow, add or extract adjacent focused enfyra_flow_step rows instead of making an oversized sourceCode block longer.',
1407
1409
  ],
@@ -1418,6 +1420,21 @@ return order && order.total > 1000`,
1418
1420
  'Use public-safe URLs for HTTP steps.',
1419
1421
  ],
1420
1422
  },
1423
+ {
1424
+ name: 'Flow create/update/delete step configs',
1425
+ code: `// ensure_create_flow_step
1426
+ { "table": "todo", "data": { "title": "Review", "status": "open" } }
1427
+
1428
+ // ensure_update_flow_step
1429
+ { "table": "todo", "id": "@FLOW_PAYLOAD.todoId", "data": { "status": "done" } }
1430
+
1431
+ // ensure_delete_flow_step
1432
+ { "table": "todo", "id": "@FLOW_PAYLOAD.todoId" }`,
1433
+ notes: [
1434
+ 'Use fixed CRUD flow step tools for single-record writes.',
1435
+ 'Use script only when a step must coordinate multiple records, compute complex data, or call packages.',
1436
+ ],
1437
+ },
1421
1438
  ],
1422
1439
  },
1423
1440
  files: {
@@ -1522,12 +1539,16 @@ ensure_page_extension({
1522
1539
  'Use enfyra_menu.label, not title.',
1523
1540
  'Sensitive admin menus should include a permission condition at creation time.',
1524
1541
  'For page extensions, create the menu first with ensure_menu and pass its id to ensure_page_extension.',
1542
+ 'Call get_extension_theme_contract before writing or reviewing page/widget/global extension UI.',
1525
1543
  'Page extensions must register the app-shell PageHeader with usePageHeaderRegistry instead of rendering a custom top header.',
1526
1544
  'Use variant: "minimal" for operational pages unless a larger header is intentionally needed.',
1527
1545
  'Do not put ordinary KPI cards in PageHeader.stats; render metrics in the extension body.',
1528
1546
  'Put page-level actions in useHeaderActionRegistry or useSubHeaderActionRegistry, destructure register first, then call it with one action or an array.',
1529
1547
  'Page extensions should be full-bleed by default and responsive from the first version.',
1530
1548
  'The extension root is already inside Enfyra admin page main; do not add root-level page padding.',
1549
+ 'Use theme tokens/classes for panels, rows, badges, borders, and text. Pair border/divide utilities with border-default or divide-[var(--border-default)] so light and dark themes stay consistent.',
1550
+ 'Keep list selection local and fetch detail rows only; do not refetch the whole list after a row click unless the list data changed.',
1551
+ '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.',
1531
1552
  '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.',
1532
1553
  ],
1533
1554
  },
@@ -27,9 +27,10 @@ export function buildMcpServerInstructions(apiBaseUrl) {
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
28
  '- 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
29
  '- Inspect narrowly. Use `inspect_table`, `inspect_route`, and `inspect_feature` for the table/route/feature being changed instead of loading broad metadata.',
30
- '- Load examples only when needed. Before generating schemas, app connection code, OAuth, Socket.IO, handlers/hooks, flows, files, guards, permissions, or extensions, call `get_enfyra_examples` with the matching category.',
30
+ '- Load examples only when needed. Before generating schemas, app connection code, OAuth, Socket.IO, handlers/hooks, flows, files, guards, permissions, or extensions, call `get_enfyra_examples` with the matching category. Before extension UI work, call `get_extension_theme_contract` and follow the app-shell/theme/security contract.',
31
31
  '- For server scripts, call `discover_script_contexts` before writing or reviewing handler/hook/flow/websocket/GraphQL logic.',
32
- '- Prefer the most specific business operation tool over raw metadata CRUD: `api_endpoint_workflow` for step-by-step endpoint work; `create_api_endpoint` only when a one-shot endpoint operation is clearly safe; route tools such as `enable_route`, `disable_route`, `delete_route`, `add_route_methods`, `public_route_methods`, and `private_route_methods`; `set_table_graphql`; `ensure_guard`; permission/rule tools; websocket tools; flow tools such as `ensure_manual_flow`, `ensure_scheduled_flow`, and fixed-type flow step tools; and extension tools such as `ensure_menu`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension`.',
32
+ '- 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/*`.',
33
+ '- Prefer the most specific business operation tool over raw metadata CRUD: `api_endpoint_workflow` for step-by-step endpoint work; `create_api_endpoint` only when a one-shot endpoint operation is clearly safe; route tools such as `enable_route`, `disable_route`, `delete_route`, `add_route_methods`, `public_route_methods`, and `private_route_methods`; `set_table_graphql`; `ensure_guard`; permission/rule tools; websocket tools; flow tools such as `ensure_manual_flow`, `ensure_scheduled_flow`, `choose_flow_step_tool`, and fixed-type flow step tools; and extension tools such as `ensure_menu`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension`.',
33
34
  '- Before saving standalone dynamic script or extension code, call `validate_dynamic_script` or `validate_extension_code` unless the chosen ensure/update tool already validates the code.',
34
35
  '- For existing script-backed records, use `trace_metadata_usage` then `get_script_source`; edit with `patch_script_source` or `update_script_source` so source is hash-checked and validated.',
35
36
  '- Validate behavior with `test_rest_endpoint`, `run_admin_test`, `test_flow_step`, or the route-specific tool before claiming a dynamic feature works.',
@@ -47,6 +48,9 @@ export function buildMcpServerInstructions(apiBaseUrl) {
47
48
  '- Script source is `sourceCode`; `compiledCode` is generated and may differ textually because macros expand. Do not warn about source/compiled mismatch unless validation or runtime behavior proves the compiled artifact is stale.',
48
49
  '- For intentional user/domain errors in scripts use `@THROW400`-style helpers or `$ctx.$throw[...]`, not `throw new Error(...)`.',
49
50
  '- Destructive operations are preview-first. Do not pass `confirm=true` until the user explicitly approves.',
51
+ '- Treat permission and security as the first design step for any route, handler, flow, extension, or data surface: decide public/private methods, authenticated route access, owner/tenant scope, and field exposure before writing feature logic.',
52
+ '- Enfyra admin UI `usePermissions()` and backend RoleGuard both use route permissions: root admin passes; direct `allowedRoutePermissions` and role `routePermissions` grant route+method access. Use `audit_route_access` and `ensure_route_access` to inspect or grant these permissions.',
53
+ '- Route permissions only let authenticated users reach the route after RoleGuard; handlers, hooks, or RLS must still enforce record ownership and tenant/project scope.',
50
54
  '- Operator posture: act from these contracts plus live metadata. Do not turn expected implementation details into speculative warnings; ask only for new product/design decisions or genuine ambiguity.',
51
55
  '',
52
56
  '### App Connection Defaults',
@@ -61,6 +65,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
61
65
  '### Direct HTTP Mapping',
62
66
  '- Route-backed table CRUD is REST: `GET /<table>?...`, `POST /<table>`, `PATCH /<table>/<id>`, `DELETE /<table>/<id>`. There is no `GET /<table>/<id>`; use a filtered list with `limit=1` or `find_one_record`.',
63
67
  '- REST route lifecycle is controlled by `enfyra_route.isEnabled`: disabled routes are not registered at runtime and return 404. Use `enable_route`/`disable_route` instead of raw route PATCH. REST public access is controlled by route `publicMethods`; otherwise direct HTTP needs Bearer JWT plus route permissions. GraphQL table data requires Bearer auth and table GraphQL enablement; anonymous root/schema probes may still return a 200 without exposing table data.',
68
+ '- Admin app page/menu paths such as `/cloud/projects/:id` are UI routes, not Enfyra API endpoints unless an enabled `enfyra_route.path` with the same path exists. Use `test_rest_endpoint` only for paths that are actual API routes under `ENFYRA_API_URL`; verify page extensions through the app URL/browser or by reading the extension/menu metadata.',
64
69
  '',
65
70
  'When the user asks for details, fetch only the relevant live context or example category instead of relying on broad memorized rules.',
66
71
  ].join('\n');
@@ -286,6 +286,40 @@ function jsonText(payload) {
286
286
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
287
287
  }
288
288
 
289
+ function getExtensionThemeContract() {
290
+ return {
291
+ action: 'extension_theme_contract',
292
+ useBefore: [
293
+ 'Call this before writing or reviewing Enfyra admin page, widget, or global extension UI.',
294
+ 'Then call validate_extension_code or an ensure_*_extension tool before saving.',
295
+ ],
296
+ layout: [
297
+ 'The extension is already mounted inside the Enfyra app shell. Do not add a duplicate page header, centered page wrapper, or root-level page padding.',
298
+ 'Page extensions should be full-bleed, responsive, and split large operations into focused pages or UTabs.',
299
+ 'Use usePageHeaderRegistry for the shell title and useHeaderActionRegistry/useSubHeaderActionRegistry for page actions.',
300
+ ],
301
+ theme: [
302
+ 'Use eApp theme tokens/classes, not hardcoded light or dark colors.',
303
+ 'Use bg-default, bg-muted, text-highlighted, text-muted, border-default when available.',
304
+ 'When using arbitrary CSS vars, prefer var(--surface-default), var(--surface-muted), var(--border-default), var(--text-primary), var(--text-secondary), and var(--text-tertiary).',
305
+ 'Never use bare border/divide-y for panels or rows: pair them with border-default or divide-[var(--border-default)]. Avoid border-black, black, slate-only, gray-only, and dark-only palettes.',
306
+ 'Status colors must remain readable in both themes; warning badges need high contrast text and a visible but not harsh border/background.',
307
+ ],
308
+ interaction: [
309
+ 'Every mutating button needs pending/disabled state, success/error feedback, and must close or update its modal when the operation completes.',
310
+ 'Do not refetch broad lists after selecting one row. Keep local selection state and fetch only the detail or mutation result needed.',
311
+ 'Use bounded pagination for operational lists. Do not replace pagination with arbitrary fixed caps such as 30 or 50.',
312
+ 'Customer-facing toasts must describe the operation. Do not surface raw job ids, flow ids, or worker ids.',
313
+ ],
314
+ security: [
315
+ 'Decide route permission, owner scope, and field exposure before writing UI or backend logic.',
316
+ 'UI checks are only guidance; handlers/hooks must independently enforce owner/root-admin authorization.',
317
+ 'Use the most specific business route or MCP tool. Do not write directly to raw tables when a domain route exists.',
318
+ ],
319
+ compactExample: '<template><section class="min-h-full w-full space-y-4"><div class="rounded-lg border border-default bg-default"><div class="border-b border-default px-4 py-3"><h2 class="text-base font-semibold text-highlighted">Title</h2><p class="text-sm text-muted">Short operational context.</p></div><div class="divide-y divide-[var(--border-default)]"><button class="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-muted/60"><span class="text-sm font-medium text-highlighted">Row</span><span class="text-sm text-muted">Open</span></button></div></div></section></template>',
320
+ };
321
+ }
322
+
289
323
  function parseJsonObjectArg(name, value, fallback = {}) {
290
324
  if (value === undefined || value === null || value === '') return fallback;
291
325
  const parsed = typeof value === 'string' ? JSON.parse(value) : value;
@@ -565,6 +599,84 @@ async function ensureFlowStep(apiUrl, {
565
599
  return { action: 'flow_step_ensured', flow: { id: getId(flow), name: flow.name }, step: { id: operation.id, key, type }, validation, operation, reload };
566
600
  }
567
601
 
602
+ const FLOW_STEP_TOOL_GUIDANCE = [
603
+ {
604
+ tool: 'ensure_query_flow_step',
605
+ type: 'query',
606
+ when: 'Read/list records from one table without custom branching or transformation.',
607
+ config: { table: 'table_name', filter: {}, fields: 'id,name', limit: 20, sort: '-createdAt' },
608
+ },
609
+ {
610
+ tool: 'ensure_create_flow_step',
611
+ type: 'create',
612
+ when: 'Create one record in one table from static config or previous flow values.',
613
+ config: { table: 'table_name', data: { field: 'value' } },
614
+ },
615
+ {
616
+ tool: 'ensure_update_flow_step',
617
+ type: 'update',
618
+ when: 'Update one known record by id.',
619
+ config: { table: 'table_name', id: '@FLOW_PAYLOAD.id', data: { field: 'value' } },
620
+ },
621
+ {
622
+ tool: 'ensure_delete_flow_step',
623
+ type: 'delete',
624
+ when: 'Delete one known record by id.',
625
+ config: { table: 'table_name', id: '@FLOW_PAYLOAD.id' },
626
+ },
627
+ {
628
+ tool: 'ensure_http_flow_step',
629
+ type: 'http',
630
+ when: 'Call an external HTTP API.',
631
+ config: { url: 'https://example.com/api', method: 'POST', headers: {}, body: {}, timeout: 10000 },
632
+ },
633
+ {
634
+ tool: 'ensure_condition_flow_step',
635
+ type: 'condition',
636
+ when: 'Branch into true/false child steps based on JavaScript truthiness.',
637
+ sourceCode: 'return Boolean(@FLOW_PAYLOAD.enabled)',
638
+ },
639
+ {
640
+ tool: 'ensure_sleep_flow_step',
641
+ type: 'sleep',
642
+ when: 'Wait for a short bounded delay.',
643
+ config: { ms: 1000 },
644
+ },
645
+ {
646
+ tool: 'ensure_trigger_flow_step',
647
+ type: 'trigger_flow',
648
+ when: 'Trigger another flow as a child/orchestration step.',
649
+ config: { flowName: 'child-flow', payload: {} },
650
+ },
651
+ {
652
+ tool: 'ensure_log_flow_step',
653
+ type: 'log',
654
+ when: 'Record a small execution note for diagnostics.',
655
+ config: { message: 'Reached step_name' },
656
+ },
657
+ {
658
+ tool: 'ensure_script_flow_step',
659
+ type: 'script',
660
+ when: 'Use only when logic needs loops, multiple tables, crypto, package calls, non-trivial transforms, or runtime checks not covered by the atomic step tools.',
661
+ sourceCode: 'return { ok: true }',
662
+ },
663
+ ];
664
+
665
+ function chooseFlowStepTool(intent) {
666
+ const text = String(intent || '').toLowerCase();
667
+ const hasAny = (patterns) => patterns.some((pattern) => pattern.test(text));
668
+ if (hasAny([/\bif\b/, /\belse\b/, /\bbranch\b/, /\bcondition\b/, /\bwhen\b/, /\bcheck\b/, /nếu/, /điều kiện/])) return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'condition');
669
+ if (hasAny([/\bhttp\b/, /\bapi\b/, /\bwebhook\b/, /\bfetch\b/, /\brequest\b/, /\bpost\b/, /\bget\b/, /\bcall\b/, /gọi api/])) return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'http');
670
+ if (hasAny([/\bsleep\b/, /\bwait\b/, /\bdelay\b/, /\bpause\b/, /chờ/, /đợi/])) return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'sleep');
671
+ if (hasAny([/\btrigger\b/, /\bchild flow\b/, /\banother flow\b/, /\bsubflow\b/, /flow khác/])) return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'trigger_flow');
672
+ if (hasAny([/\bdelete\b/, /\bremove\b/, /\bdestroy\b/, /xóa/, /xoá/])) return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'delete');
673
+ if (hasAny([/\bupdate\b/, /\bpatch\b/, /\bset\b/, /\bmark\b/, /\bchange\b/, /cập nhật/, /đánh dấu/])) return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'update');
674
+ if (hasAny([/\bcreate\b/, /\binsert\b/, /\badd\b/, /\bstore\b/, /\bsave\b/, /tạo/, /thêm/, /lưu/])) return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'create');
675
+ if (hasAny([/\blog\b/, /\bdebug\b/, /\btrace\b/, /ghi log/])) return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'log');
676
+ if (hasAny([/\bquery\b/, /\bfind\b/, /\blist\b/, /\bread\b/, /\bload\b/, /\bcount\b/, /\bsearch\b/, /đọc/, /tìm/, /liệt kê/])) return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'query');
677
+ return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'script');
678
+ }
679
+
568
680
  function normalizeEndpointAccess(anonymousAccess, makePublic) {
569
681
  if (makePublic !== undefined) return makePublic ? 'public' : 'private';
570
682
  return anonymousAccess || 'private';
@@ -901,6 +1013,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
901
1013
  [
902
1014
  'Validate Enfyra admin extension code before saving it to enfyra_extension.',
903
1015
  'Use this for Vue SFC page/widget/global extension code. It calls /enfyra_extension/preview and does not save anything.',
1016
+ 'Call get_extension_theme_contract first when generating or reviewing UI.',
904
1017
  ].join(' '),
905
1018
  {
906
1019
  code: z.string().describe('Vue SFC or compiled extension bundle code.'),
@@ -912,6 +1025,13 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
912
1025
  }),
913
1026
  );
914
1027
 
1028
+ server.tool(
1029
+ 'get_extension_theme_contract',
1030
+ 'Return the concise Enfyra admin extension UI/theme/security contract. Call before writing or reviewing extension UI.',
1031
+ {},
1032
+ async () => jsonText(getExtensionThemeContract()),
1033
+ );
1034
+
915
1035
  server.tool(
916
1036
  'set_table_graphql',
917
1037
  'Business operation: enable or disable GraphQL for one table through enfyra_graphql, then reload GraphQL. REST route methods do not control GraphQL.',
@@ -1519,6 +1639,28 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1519
1639
  })),
1520
1640
  );
1521
1641
 
1642
+ server.tool(
1643
+ 'choose_flow_step_tool',
1644
+ 'Dry-run helper: choose the most specific Enfyra flow step tool for one intended step before mutating flow metadata.',
1645
+ {
1646
+ intent: z.string().describe('Plain-language description of what this one flow step should do.'),
1647
+ },
1648
+ async ({ intent }) => {
1649
+ const recommendation = chooseFlowStepTool(intent);
1650
+ return jsonText({
1651
+ action: 'flow_step_tool_recommended',
1652
+ intent,
1653
+ recommendation,
1654
+ availableStepTools: FLOW_STEP_TOOL_GUIDANCE,
1655
+ nextSteps: [
1656
+ `Call ${recommendation.tool} with a stable key and order.`,
1657
+ 'Use ensure_script_flow_step only when the atomic tools cannot express the behavior.',
1658
+ 'After saving script or condition steps, use test_flow_step before relying on the flow.',
1659
+ ],
1660
+ });
1661
+ },
1662
+ );
1663
+
1522
1664
  server.tool(
1523
1665
  'ensure_script_flow_step',
1524
1666
  'Business operation: create or update one script flow step. Use this for JavaScript/TypeScript flow logic instead of choosing type=script manually.',
@@ -1595,6 +1737,60 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1595
1737
  })),
1596
1738
  );
1597
1739
 
1740
+ server.tool(
1741
+ 'ensure_create_flow_step',
1742
+ 'Business operation: create or update one create-record flow step. Use this for a single table insert instead of writing script code.',
1743
+ {
1744
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
1745
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
1746
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
1747
+ config: z.string().describe('Step config JSON object: { "table": "...", "data": { ... } }.'),
1748
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
1749
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1750
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1751
+ },
1752
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1753
+ ...input,
1754
+ type: 'create',
1755
+ })),
1756
+ );
1757
+
1758
+ server.tool(
1759
+ 'ensure_update_flow_step',
1760
+ 'Business operation: create or update one update-record flow step. Use this for a single table update by id instead of writing script code.',
1761
+ {
1762
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
1763
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
1764
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
1765
+ config: z.string().describe('Step config JSON object: { "table": "...", "id": "...", "data": { ... } }.'),
1766
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
1767
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1768
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1769
+ },
1770
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1771
+ ...input,
1772
+ type: 'update',
1773
+ })),
1774
+ );
1775
+
1776
+ server.tool(
1777
+ 'ensure_delete_flow_step',
1778
+ 'Business operation: create or update one delete-record flow step. Use this for a single table delete by id instead of writing script code.',
1779
+ {
1780
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
1781
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
1782
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
1783
+ config: z.string().describe('Step config JSON object: { "table": "...", "id": "..." }.'),
1784
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
1785
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1786
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1787
+ },
1788
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1789
+ ...input,
1790
+ type: 'delete',
1791
+ })),
1792
+ );
1793
+
1598
1794
  server.tool(
1599
1795
  'ensure_sleep_flow_step',
1600
1796
  'Business operation: create or update one sleep/wait flow step. Use this for delays instead of choosing type=sleep manually.',
@@ -1613,6 +1809,24 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1613
1809
  })),
1614
1810
  );
1615
1811
 
1812
+ server.tool(
1813
+ 'ensure_log_flow_step',
1814
+ 'Business operation: create or update one log flow step. Use this for lightweight execution diagnostics instead of script code.',
1815
+ {
1816
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
1817
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
1818
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
1819
+ config: z.string().describe('Step config JSON object: { "message": "..." }.'),
1820
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
1821
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1822
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1823
+ },
1824
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1825
+ ...input,
1826
+ type: 'log',
1827
+ })),
1828
+ );
1829
+
1616
1830
  server.tool(
1617
1831
  'ensure_trigger_flow_step',
1618
1832
  'Business operation: create or update one child-flow trigger step. Use this for flow-to-flow orchestration instead of choosing type=trigger_flow manually.',
@@ -1652,7 +1866,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1652
1866
 
1653
1867
  server.tool(
1654
1868
  'ensure_page_extension',
1655
- 'Business operation: create or update one page extension attached to an existing menu. Validates extension code before save.',
1869
+ 'Business operation: create or update one page extension attached to an existing menu. Validates extension code before save. Call get_extension_theme_contract first for UI work.',
1656
1870
  {
1657
1871
  name: z.string().describe('Extension unique name.'),
1658
1872
  code: z.string().describe('Vue SFC extension code.'),
@@ -1669,7 +1883,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1669
1883
 
1670
1884
  server.tool(
1671
1885
  'ensure_global_extension',
1672
- 'Business operation: create or update one global shell extension. Validates extension code before save and rejects menu coupling.',
1886
+ 'Business operation: create or update one global shell extension. Validates extension code before save and rejects menu coupling. Call get_extension_theme_contract first for UI work.',
1673
1887
  {
1674
1888
  name: z.string().describe('Extension unique name.'),
1675
1889
  code: z.string().describe('Vue SFC extension code.'),
@@ -1685,7 +1899,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1685
1899
 
1686
1900
  server.tool(
1687
1901
  'ensure_widget_extension',
1688
- 'Business operation: create or update one widget extension. Validates extension code before save and rejects menu coupling.',
1902
+ 'Business operation: create or update one widget extension. Validates extension code before save and rejects menu coupling. Call get_extension_theme_contract first for UI work.',
1689
1903
  {
1690
1904
  name: z.string().describe('Extension unique name.'),
1691
1905
  code: z.string().describe('Vue SFC extension code.'),
@@ -114,6 +114,73 @@ const FILTER_OPERATORS = [
114
114
  '_not',
115
115
  ];
116
116
 
117
+ const DEFAULT_ME_PERMISSION_FIELDS = [
118
+ 'id',
119
+ 'email',
120
+ 'isRootAdmin',
121
+ 'role.id',
122
+ 'role.name',
123
+ 'role.routePermissions.id',
124
+ 'role.routePermissions.isEnabled',
125
+ 'role.routePermissions.methods.id',
126
+ 'role.routePermissions.methods.name',
127
+ 'role.routePermissions.route.id',
128
+ 'role.routePermissions.route.path',
129
+ 'role.routePermissions.allowedUsers.id',
130
+ 'allowedRoutePermissions.id',
131
+ 'allowedRoutePermissions.isEnabled',
132
+ 'allowedRoutePermissions.methods.id',
133
+ 'allowedRoutePermissions.methods.name',
134
+ 'allowedRoutePermissions.route.id',
135
+ 'allowedRoutePermissions.route.path',
136
+ 'allowedRoutePermissions.allowedUsers.id',
137
+ ];
138
+
139
+ const MCP_PERMISSION_REQUIREMENTS = [
140
+ {
141
+ area: 'script validation',
142
+ tools: ['validate_dynamic_script', 'create_handler', 'create_pre_hook', 'create_post_hook', 'patch_script_source', 'update_script_source', 'ensure_script_flow_step', 'ensure_condition_flow_step', 'ensure_websocket_event'],
143
+ route: '/admin/script/validate',
144
+ methods: ['POST'],
145
+ },
146
+ {
147
+ area: 'flow and websocket test runner',
148
+ tools: ['run_admin_test', 'test_flow_step'],
149
+ route: '/admin/test/run',
150
+ methods: ['POST'],
151
+ },
152
+ {
153
+ area: 'manual flow trigger',
154
+ tools: ['trigger_flow'],
155
+ route: '/admin/flow/trigger/:id',
156
+ methods: ['POST'],
157
+ },
158
+ {
159
+ area: 'route cache reload',
160
+ tools: ['reload_routes', 'enable_route', 'disable_route', 'delete_route', 'public_route_methods', 'private_route_methods', 'add_route_methods', 'replace_route_methods', 'remove_route_methods', 'ensure_route_access'],
161
+ route: '/admin/reload/routes',
162
+ methods: ['POST'],
163
+ },
164
+ {
165
+ area: 'metadata cache reload',
166
+ tools: ['reload_metadata'],
167
+ route: '/admin/reload/metadata',
168
+ methods: ['POST'],
169
+ },
170
+ {
171
+ area: 'GraphQL cache reload',
172
+ tools: ['reload_graphql', 'set_table_graphql'],
173
+ route: '/admin/reload/graphql',
174
+ methods: ['POST'],
175
+ },
176
+ {
177
+ area: 'full cache reload',
178
+ tools: ['reload_all'],
179
+ route: '/admin/reload',
180
+ methods: ['POST'],
181
+ },
182
+ ];
183
+
117
184
  const FIELD_PERMISSION_CONDITION_OPERATORS = [
118
185
  '_eq',
119
186
  '_neq',
@@ -302,6 +369,94 @@ function resultRecordId(result) {
302
369
  return getId(firstDataRecord(result));
303
370
  }
304
371
 
372
+ function normalizePermissionRoute(routePath) {
373
+ const value = String(routePath || '').trim();
374
+ return value.startsWith('/') ? value : `/${value}`;
375
+ }
376
+
377
+ function methodNames(permission) {
378
+ return normalizeMethodNames((permission?.methods || []).map((method) => method?.name || method));
379
+ }
380
+
381
+ function permissionAllowedUserIds(permission) {
382
+ return (permission?.allowedUsers || []).map((user) => String(refId(user))).filter(Boolean);
383
+ }
384
+
385
+ function permissionMatchesUser(permission, userId) {
386
+ const allowed = permissionAllowedUserIds(permission);
387
+ if (!allowed.length) return true;
388
+ return userId ? allowed.includes(String(userId)) : false;
389
+ }
390
+
391
+ function directPermissionMatchesUser(permission, userId) {
392
+ const allowed = permissionAllowedUserIds(permission);
393
+ return userId ? allowed.includes(String(userId)) : false;
394
+ }
395
+
396
+ function userHasRoutePermission(user, routePath, method) {
397
+ if (!user) return false;
398
+ if (user.isRootAdmin) return true;
399
+
400
+ const normalizedRoute = normalizePermissionRoute(routePath);
401
+ const normalizedMethod = String(method || '').toUpperCase();
402
+ const userId = getId(user);
403
+ const directPermissions = user.allowedRoutePermissions || [];
404
+ const rolePermissions = user.role?.routePermissions || [];
405
+
406
+ const matchesRouteAndMethod = (permission) => (
407
+ permission?.isEnabled !== false
408
+ && permission?.route?.path === normalizedRoute
409
+ && methodNames(permission).includes(normalizedMethod)
410
+ );
411
+
412
+ return directPermissions.some((permission) => (
413
+ matchesRouteAndMethod(permission)
414
+ && directPermissionMatchesUser(permission, userId)
415
+ )) || rolePermissions.some((permission) => (
416
+ matchesRouteAndMethod(permission)
417
+ && permissionMatchesUser(permission, userId)
418
+ ));
419
+ }
420
+
421
+ function summarizePermissionProfile(user) {
422
+ const requirements = MCP_PERMISSION_REQUIREMENTS.map((requirement) => {
423
+ const methods = requirement.methods.map((method) => ({
424
+ method,
425
+ allowed: userHasRoutePermission(user, requirement.route, method),
426
+ }));
427
+ return {
428
+ ...requirement,
429
+ methods,
430
+ allowed: methods.every((item) => item.allowed),
431
+ };
432
+ });
433
+
434
+ return {
435
+ user: user ? {
436
+ id: getId(user),
437
+ email: user.email || null,
438
+ isRootAdmin: !!user.isRootAdmin,
439
+ role: user.role ? {
440
+ id: getId(user.role),
441
+ name: user.role.name || null,
442
+ } : null,
443
+ } : null,
444
+ permissionModel: {
445
+ sameAsAdminUi: 'Mirrors Enfyra admin usePermissions(): root admin passes; otherwise direct allowedRoutePermissions are checked before role.routePermissions.',
446
+ publicMethods: 'Anonymous REST access is controlled by route.publicMethods; this profile only reports authenticated route permissions for the configured token.',
447
+ },
448
+ mcpRequirements: requirements,
449
+ missingRequirements: requirements
450
+ .filter((item) => !item.allowed)
451
+ .map((item) => ({
452
+ area: item.area,
453
+ route: item.route,
454
+ methods: item.methods.filter((method) => !method.allowed).map((method) => method.method),
455
+ tools: item.tools,
456
+ })),
457
+ };
458
+ }
459
+
305
460
  function parseJsonArg(value, fallback = undefined) {
306
461
  if (value === undefined || value === null || value === '') return fallback;
307
462
  return JSON.parse(value);
@@ -2130,6 +2285,7 @@ server.tool(
2130
2285
  [
2131
2286
  'Execute a real REST request against the configured Enfyra API base.',
2132
2287
  'Use this after inspecting a route or changing handlers/hooks/guards. Pass paths like /enfyra_table?limit=1, not external URLs.',
2288
+ 'Do not use this for admin app page/menu routes such as /cloud/projects/:id unless inspect_route confirms an API route with that exact path.',
2133
2289
  ].join(' '),
2134
2290
  {
2135
2291
  method: z.string().optional().default('GET').describe('HTTP method name. Must exist in enfyra_method.name for Enfyra route-backed calls.'),
@@ -2734,6 +2890,22 @@ server.tool('get_current_user', 'Get current authenticated user info', {}, async
2734
2890
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
2735
2891
  });
2736
2892
 
2893
+ server.tool(
2894
+ 'get_permission_profile',
2895
+ [
2896
+ 'Inspect the current token permission profile using the same route-permission model as Enfyra admin UI usePermissions().',
2897
+ 'Use this before debugging 403s or before relying on admin helper tools with a non-root API token.',
2898
+ 'Reports which MCP tool groups need route permissions such as /admin/script/validate, /admin/test/run, /admin/flow/trigger/:id, and reload endpoints.',
2899
+ ].join(' '),
2900
+ {},
2901
+ async () => {
2902
+ const fields = DEFAULT_ME_PERMISSION_FIELDS.join(',');
2903
+ const result = await fetchAPI(ENFYRA_API_URL, `/me?fields=${encodeURIComponent(fields)}`);
2904
+ const user = firstDataRecord(result);
2905
+ return jsonContent(summarizePermissionProfile(user));
2906
+ },
2907
+ );
2908
+
2737
2909
  server.tool('get_all_roles', 'Get all role definitions', {}, async () => {
2738
2910
  const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_role?limit=100');
2739
2911
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };