@enfyra/mcp-server 0.0.107 → 0.0.109

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
 
@@ -231,10 +231,14 @@ Do not create custom login/logout/me routes that manually set Enfyra token cooki
231
231
 
232
232
  The MCP server exposes tools for metadata discovery, 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, and `get_enfyra_api_context`.
233
233
 
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`, `set_public_route_methods`, and `private_route_methods` for that access boundary.
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
+
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.
235
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.107",
3
+ "version": "0.0.109",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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: {
@@ -1528,6 +1545,7 @@ ensure_page_extension({
1528
1545
  'Put page-level actions in useHeaderActionRegistry or useSubHeaderActionRegistry, destructure register first, then call it with one action or an array.',
1529
1546
  'Page extensions should be full-bleed by default and responsive from the first version.',
1530
1547
  'The extension root is already inside Enfyra admin page main; do not add root-level page padding.',
1548
+ '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
1549
  '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
1550
  ],
1533
1551
  },
@@ -29,14 +29,15 @@ export function buildMcpServerInstructions(apiBaseUrl) {
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
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.',
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.',
36
37
  '',
37
38
  '### Core Contracts',
38
39
  '- Tool JSON responses use `responseFormat: "json+columnar-v1"`. Large arrays of objects may be encoded as `{ format: "columnar-v1", columns: [...], rows: [[...]], rowCount }` only when that is smaller than raw JSON; read each row value by matching `columns[index]` to `rows[n][index]`. Do not guess object keys inside `rows`. `compressionStats` estimates token savings and includes whether compression was applied; use it only when the user asks about savings.',
39
- '- `query_table` and `get_all_routes` require explicit intent: pass `limit` for bounded reads or `all: true` for a complete list. Do not invent arbitrary limits such as 30 or 50.',
40
+ '- `query_table`, `get_all_routes`, and `get_all_tables` require explicit intent: pass `limit` for bounded reads or `all: true` for a complete list. Do not invent arbitrary limits such as 30 or 50.',
40
41
  '- Read tools are minimal by default. Pass explicit `fields`; use metadata inspection before guessing field/relation names. Field exclusion mode exists: `fields=-compiledCode`, and `fields=id,-compiledCode` still means all readable fields except `compiledCode`.',
41
42
  '- Mutations return ids/status by default. Re-read with `find_one_record` or `query_table` and explicit `fields` when the saved row matters.',
42
43
  '- Dynamic repository reads use `filter`, not `where`: `@REPOS.table.find({ filter: {...} })`, `#table.find({ filter: {...} })`, and `exists(filter)`.',
@@ -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');
@@ -72,7 +72,7 @@ export function rejectUnsafeRelationDefinitionPayload(tableName, payload) {
72
72
  if (forbidden.length > 0) {
73
73
  throw new Error(
74
74
  `Do not send physical FK/junction fields to enfyra_relation: ${forbidden.join(', ')}. ` +
75
- 'Use create_relation/add_relation with targetTable/type/propertyName; Enfyra derives physical columns.'
75
+ 'Use create_relation with targetTable/type/propertyName; Enfyra derives physical columns.'
76
76
  );
77
77
  }
78
78
  }
@@ -565,6 +565,84 @@ async function ensureFlowStep(apiUrl, {
565
565
  return { action: 'flow_step_ensured', flow: { id: getId(flow), name: flow.name }, step: { id: operation.id, key, type }, validation, operation, reload };
566
566
  }
567
567
 
568
+ const FLOW_STEP_TOOL_GUIDANCE = [
569
+ {
570
+ tool: 'ensure_query_flow_step',
571
+ type: 'query',
572
+ when: 'Read/list records from one table without custom branching or transformation.',
573
+ config: { table: 'table_name', filter: {}, fields: 'id,name', limit: 20, sort: '-createdAt' },
574
+ },
575
+ {
576
+ tool: 'ensure_create_flow_step',
577
+ type: 'create',
578
+ when: 'Create one record in one table from static config or previous flow values.',
579
+ config: { table: 'table_name', data: { field: 'value' } },
580
+ },
581
+ {
582
+ tool: 'ensure_update_flow_step',
583
+ type: 'update',
584
+ when: 'Update one known record by id.',
585
+ config: { table: 'table_name', id: '@FLOW_PAYLOAD.id', data: { field: 'value' } },
586
+ },
587
+ {
588
+ tool: 'ensure_delete_flow_step',
589
+ type: 'delete',
590
+ when: 'Delete one known record by id.',
591
+ config: { table: 'table_name', id: '@FLOW_PAYLOAD.id' },
592
+ },
593
+ {
594
+ tool: 'ensure_http_flow_step',
595
+ type: 'http',
596
+ when: 'Call an external HTTP API.',
597
+ config: { url: 'https://example.com/api', method: 'POST', headers: {}, body: {}, timeout: 10000 },
598
+ },
599
+ {
600
+ tool: 'ensure_condition_flow_step',
601
+ type: 'condition',
602
+ when: 'Branch into true/false child steps based on JavaScript truthiness.',
603
+ sourceCode: 'return Boolean(@FLOW_PAYLOAD.enabled)',
604
+ },
605
+ {
606
+ tool: 'ensure_sleep_flow_step',
607
+ type: 'sleep',
608
+ when: 'Wait for a short bounded delay.',
609
+ config: { ms: 1000 },
610
+ },
611
+ {
612
+ tool: 'ensure_trigger_flow_step',
613
+ type: 'trigger_flow',
614
+ when: 'Trigger another flow as a child/orchestration step.',
615
+ config: { flowName: 'child-flow', payload: {} },
616
+ },
617
+ {
618
+ tool: 'ensure_log_flow_step',
619
+ type: 'log',
620
+ when: 'Record a small execution note for diagnostics.',
621
+ config: { message: 'Reached step_name' },
622
+ },
623
+ {
624
+ tool: 'ensure_script_flow_step',
625
+ type: 'script',
626
+ 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.',
627
+ sourceCode: 'return { ok: true }',
628
+ },
629
+ ];
630
+
631
+ function chooseFlowStepTool(intent) {
632
+ const text = String(intent || '').toLowerCase();
633
+ const hasAny = (patterns) => patterns.some((pattern) => pattern.test(text));
634
+ 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');
635
+ 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');
636
+ if (hasAny([/\bsleep\b/, /\bwait\b/, /\bdelay\b/, /\bpause\b/, /chờ/, /đợi/])) return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'sleep');
637
+ 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');
638
+ if (hasAny([/\bdelete\b/, /\bremove\b/, /\bdestroy\b/, /xóa/, /xoá/])) return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'delete');
639
+ 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');
640
+ 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');
641
+ if (hasAny([/\blog\b/, /\bdebug\b/, /\btrace\b/, /ghi log/])) return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'log');
642
+ 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');
643
+ return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'script');
644
+ }
645
+
568
646
  function normalizeEndpointAccess(anonymousAccess, makePublic) {
569
647
  if (makePublic !== undefined) return makePublic ? 'public' : 'private';
570
648
  return anonymousAccess || 'private';
@@ -1047,22 +1125,6 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1047
1125
  })),
1048
1126
  );
1049
1127
 
1050
- server.tool(
1051
- 'set_public_route_methods',
1052
- 'Business operation: replace a route publicMethods list exactly.',
1053
- {
1054
- path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
1055
- routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
1056
- methods: z.array(z.string()).describe('Exact HTTP method names that should be public. Use an empty array to make all methods private.'),
1057
- },
1058
- async ({ path, routeId, methods }) => jsonText(await updateRoutePublicMethods(ENFYRA_API_URL, {
1059
- path,
1060
- routeId,
1061
- methods,
1062
- mode: 'replace',
1063
- })),
1064
- );
1065
-
1066
1128
  server.tool(
1067
1129
  'private_route_methods',
1068
1130
  'Business operation: make specific public route methods private again.',
@@ -1535,6 +1597,28 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1535
1597
  })),
1536
1598
  );
1537
1599
 
1600
+ server.tool(
1601
+ 'choose_flow_step_tool',
1602
+ 'Dry-run helper: choose the most specific Enfyra flow step tool for one intended step before mutating flow metadata.',
1603
+ {
1604
+ intent: z.string().describe('Plain-language description of what this one flow step should do.'),
1605
+ },
1606
+ async ({ intent }) => {
1607
+ const recommendation = chooseFlowStepTool(intent);
1608
+ return jsonText({
1609
+ action: 'flow_step_tool_recommended',
1610
+ intent,
1611
+ recommendation,
1612
+ availableStepTools: FLOW_STEP_TOOL_GUIDANCE,
1613
+ nextSteps: [
1614
+ `Call ${recommendation.tool} with a stable key and order.`,
1615
+ 'Use ensure_script_flow_step only when the atomic tools cannot express the behavior.',
1616
+ 'After saving script or condition steps, use test_flow_step before relying on the flow.',
1617
+ ],
1618
+ });
1619
+ },
1620
+ );
1621
+
1538
1622
  server.tool(
1539
1623
  'ensure_script_flow_step',
1540
1624
  'Business operation: create or update one script flow step. Use this for JavaScript/TypeScript flow logic instead of choosing type=script manually.',
@@ -1611,6 +1695,60 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1611
1695
  })),
1612
1696
  );
1613
1697
 
1698
+ server.tool(
1699
+ 'ensure_create_flow_step',
1700
+ 'Business operation: create or update one create-record flow step. Use this for a single table insert instead of writing script code.',
1701
+ {
1702
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
1703
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
1704
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
1705
+ config: z.string().describe('Step config JSON object: { "table": "...", "data": { ... } }.'),
1706
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
1707
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1708
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1709
+ },
1710
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1711
+ ...input,
1712
+ type: 'create',
1713
+ })),
1714
+ );
1715
+
1716
+ server.tool(
1717
+ 'ensure_update_flow_step',
1718
+ 'Business operation: create or update one update-record flow step. Use this for a single table update by id instead of writing script code.',
1719
+ {
1720
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
1721
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
1722
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
1723
+ config: z.string().describe('Step config JSON object: { "table": "...", "id": "...", "data": { ... } }.'),
1724
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
1725
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1726
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1727
+ },
1728
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1729
+ ...input,
1730
+ type: 'update',
1731
+ })),
1732
+ );
1733
+
1734
+ server.tool(
1735
+ 'ensure_delete_flow_step',
1736
+ 'Business operation: create or update one delete-record flow step. Use this for a single table delete by id instead of writing script code.',
1737
+ {
1738
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
1739
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
1740
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
1741
+ config: z.string().describe('Step config JSON object: { "table": "...", "id": "..." }.'),
1742
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
1743
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1744
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1745
+ },
1746
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1747
+ ...input,
1748
+ type: 'delete',
1749
+ })),
1750
+ );
1751
+
1614
1752
  server.tool(
1615
1753
  'ensure_sleep_flow_step',
1616
1754
  'Business operation: create or update one sleep/wait flow step. Use this for delays instead of choosing type=sleep manually.',
@@ -1629,6 +1767,24 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1629
1767
  })),
1630
1768
  );
1631
1769
 
1770
+ server.tool(
1771
+ 'ensure_log_flow_step',
1772
+ 'Business operation: create or update one log flow step. Use this for lightweight execution diagnostics instead of script code.',
1773
+ {
1774
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
1775
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
1776
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
1777
+ config: z.string().describe('Step config JSON object: { "message": "..." }.'),
1778
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
1779
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
1780
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
1781
+ },
1782
+ async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
1783
+ ...input,
1784
+ type: 'log',
1785
+ })),
1786
+ );
1787
+
1632
1788
  server.tool(
1633
1789
  'ensure_trigger_flow_step',
1634
1790
  'Business operation: create or update one child-flow trigger step. Use this for flow-to-flow orchestration instead of choosing type=trigger_flow manually.',
@@ -381,7 +381,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
381
381
  targetColumn: target,
382
382
  preservedColumnIds: beforeIds.filter((id) => id !== String(columnId)),
383
383
  destructive: true,
384
- next: 'Call delete_column/remove_column again with confirm=true to drop the physical column and metadata.',
384
+ next: 'Call delete_column again with confirm=true to drop the physical column and metadata.',
385
385
  }, null, 2) }],
386
386
  };
387
387
  }
@@ -423,7 +423,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
423
423
  targetRelation: target,
424
424
  preservedRelationIds: beforeIds.filter((id) => id !== String(relationId)),
425
425
  destructive: true,
426
- next: 'Call delete_relation/remove_relation again with confirm=true to drop relation metadata and any derived FK/junction structures.',
426
+ next: 'Call delete_relation again with confirm=true to drop relation metadata and any derived FK/junction structures.',
427
427
  }, null, 2) }],
428
428
  };
429
429
  }
@@ -699,18 +699,6 @@ export function registerTableTools(server, ENFYRA_API_URL) {
699
699
  appendColumnToTable
700
700
  );
701
701
 
702
- server.tool(
703
- 'add_column',
704
- [
705
- 'Alias for create_column. Add a column to an existing table through the canonical enfyra_table cascade.',
706
- 'Use this for schema additions, including hidden secret fields with isPublished=false.',
707
- 'Reads full table metadata and skips non-persisted generated/derived column metadata without id/_id when rebuilding the table columns payload.',
708
- 'Run schema changes sequentially — migration locks DB per operation.',
709
- ].join(' '),
710
- columnCreateSchema,
711
- appendColumnToTable
712
- );
713
-
714
702
  // ─── UPDATE COLUMN ───
715
703
 
716
704
  server.tool(
@@ -788,18 +776,6 @@ export function registerTableTools(server, ENFYRA_API_URL) {
788
776
  removeColumnFromTable
789
777
  );
790
778
 
791
- server.tool(
792
- 'remove_column',
793
- [
794
- 'Alias for delete_column. Remove a column through the canonical enfyra_table cascade.',
795
- 'This drops the physical column. Confirm destructive schema changes before calling.',
796
- 'Reads full table metadata and skips non-persisted generated/derived column metadata without id/_id when rebuilding the table columns payload.',
797
- 'Run schema changes sequentially — migration locks DB per operation.',
798
- ].join(' '),
799
- columnDeleteSchema,
800
- removeColumnFromTable
801
- );
802
-
803
779
  // ─── CREATE RELATION ───
804
780
 
805
781
  server.tool(
@@ -817,18 +793,6 @@ export function registerTableTools(server, ENFYRA_API_URL) {
817
793
  appendRelationToTable
818
794
  );
819
795
 
820
- server.tool(
821
- 'add_relation',
822
- [
823
- 'Alias for create_relation. Add a relation through the canonical enfyra_table cascade.',
824
- 'sourceTableId and targetTableId may be table ids, exact table names, or aliases.',
825
- 'Use relation propertyName only; never provide physical FK or junction column names.',
826
- 'Run schema changes sequentially — migration locks DB per operation.',
827
- ].join(' '),
828
- relationCreateSchema,
829
- appendRelationToTable
830
- );
831
-
832
796
  // ─── DELETE RELATION ───
833
797
 
834
798
  server.tool(
@@ -844,13 +808,4 @@ export function registerTableTools(server, ENFYRA_API_URL) {
844
808
  removeRelationFromTable
845
809
  );
846
810
 
847
- server.tool(
848
- 'remove_relation',
849
- [
850
- 'Alias for delete_relation. Remove a relation through the canonical enfyra_table cascade.',
851
- 'This can drop FK columns or junction tables. Confirm destructive schema changes before calling.',
852
- ].join(' '),
853
- relationDeleteSchema,
854
- removeRelationFromTable
855
- );
856
811
  }
@@ -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.'),
@@ -2468,137 +2624,6 @@ server.tool(
2468
2624
  },
2469
2625
  );
2470
2626
 
2471
- server.tool(
2472
- 'create_column_rule',
2473
- [
2474
- 'Create a REST body validation rule for a table column.',
2475
- 'Use inspect_table first to confirm validateBody, column type, and existing rules. Rule value is JSON; common shape is {"v": ...}.',
2476
- ].join(' '),
2477
- {
2478
- tableName: z.string().describe('Table name or alias'),
2479
- columnName: z.string().describe('Column name'),
2480
- ruleType: z.enum(['min', 'max', 'minLength', 'maxLength', 'pattern', 'format', 'minItems', 'maxItems', 'custom']).describe('Validation rule type'),
2481
- value: z.string().optional().describe('Rule payload JSON, e.g. {"v":10} or {"v":"email"}'),
2482
- message: z.string().optional().describe('Custom validation error message'),
2483
- description: z.string().optional().describe('Admin note'),
2484
- isEnabled: z.boolean().optional().default(true).describe('Enable the rule immediately'),
2485
- },
2486
- async ({ tableName, columnName, ruleType, value, message, description, isEnabled }) => {
2487
- const { tables } = await getMetadataTables();
2488
- const table = resolveTableOrThrow(tables, tableName);
2489
- const column = resolveFieldOrThrow(table, columnName, 'column');
2490
- const body = {
2491
- ruleType,
2492
- value: parseJsonArg(value, null),
2493
- message,
2494
- description,
2495
- isEnabled,
2496
- column: { id: getId(column) },
2497
- };
2498
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_column_rule', {
2499
- method: 'POST',
2500
- body: JSON.stringify(body),
2501
- });
2502
- return { content: [{ type: 'text', text: `Column rule created for ${table.name}.${column.name}.\n${JSON.stringify(result, null, 2)}` }] };
2503
- },
2504
- );
2505
-
2506
- server.tool(
2507
- 'create_field_permission',
2508
- [
2509
- 'Create a field permission for one column or relation.',
2510
- 'Exactly one of columnName or relationName is required. Scope requires roleId or allowedUserIds. Conditions use the field permission condition DSL, not the full query DSL.',
2511
- ].join(' '),
2512
- {
2513
- tableName: z.string().describe('Table name or alias'),
2514
- columnName: z.string().optional().describe('Column name to protect'),
2515
- relationName: z.string().optional().describe('Relation propertyName to protect'),
2516
- action: z.enum(['read', 'create', 'update']).default('read').describe('Action this permission applies to'),
2517
- effect: z.enum(['allow', 'deny']).default('allow').describe('Allow or deny this field action'),
2518
- roleId: z.union([z.string(), z.number()]).optional().describe('Role id scope'),
2519
- allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Specific user ids scope'),
2520
- condition: z.string().optional().describe('Optional condition JSON using field permission condition DSL'),
2521
- description: z.string().optional().describe('Admin note'),
2522
- isEnabled: z.boolean().optional().default(true).describe('Enable immediately'),
2523
- },
2524
- async ({ tableName, columnName, relationName, action, effect, roleId, allowedUserIds, condition, description, isEnabled }) => {
2525
- if (!!columnName === !!relationName) throw new Error('Provide exactly one of columnName or relationName');
2526
- if (!roleId && (!allowedUserIds || allowedUserIds.length === 0)) {
2527
- throw new Error('Provide roleId or allowedUserIds');
2528
- }
2529
- const { tables } = await getMetadataTables();
2530
- const table = resolveTableOrThrow(tables, tableName);
2531
- const body = {
2532
- isEnabled,
2533
- description,
2534
- action,
2535
- effect,
2536
- condition: parseJsonArg(condition, null),
2537
- ...(roleId ? { role: { id: roleId } } : {}),
2538
- ...(allowedUserIds?.length ? { allowedUsers: allowedUserIds.map((id) => ({ id })) } : {}),
2539
- };
2540
- if (columnName) {
2541
- body.column = { id: getId(resolveFieldOrThrow(table, columnName, 'column')) };
2542
- } else {
2543
- body.relation = { id: getId(resolveFieldOrThrow(table, relationName, 'relation')) };
2544
- }
2545
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_field_permission', {
2546
- method: 'POST',
2547
- body: JSON.stringify(body),
2548
- });
2549
- return { content: [{ type: 'text', text: `Field permission created on ${table.name}.${columnName || relationName}.\n${JSON.stringify(result, null, 2)}` }] };
2550
- },
2551
- );
2552
-
2553
- server.tool(
2554
- 'create_route_permission',
2555
- [
2556
- 'Create route access permission for a route and REST methods.',
2557
- 'Use this when a non-root role/user should access an authenticated route. publicMethods are for public access; route permissions are for authenticated role/user access.',
2558
- ].join(' '),
2559
- {
2560
- path: z.string().optional().describe('Route path, e.g. /enfyra_user'),
2561
- routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
2562
- methods: z.array(z.string()).describe('REST method names this permission allows. Each value must exist in enfyra_method.name.'),
2563
- roleId: z.union([z.string(), z.number()]).optional().describe('Role id scope'),
2564
- allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Specific user ids scope'),
2565
- description: z.string().optional().describe('Admin note'),
2566
- isEnabled: z.boolean().optional().default(true).describe('Enable immediately'),
2567
- },
2568
- async ({ path, routeId, methods, roleId, allowedUserIds, description, isEnabled }) => {
2569
- if (!path && !routeId) throw new Error('Provide path or routeId');
2570
- if (!roleId && (!allowedUserIds || allowedUserIds.length === 0)) {
2571
- throw new Error('Provide roleId or allowedUserIds');
2572
- }
2573
- const routes = await fetchAll('/enfyra_route?limit=1000');
2574
- const route = routes.find((item) => (
2575
- routeId ? sameId(getId(item), routeId) : item.path === normalizeRestPath(path)
2576
- ));
2577
- if (!route) throw new Error(`Route not found: ${routeId || path}`);
2578
- const methodMap = await getMethodMap();
2579
- const body = {
2580
- isEnabled,
2581
- description,
2582
- route: { id: getId(route) },
2583
- methods: resolveMethodIds(methodMap, methods),
2584
- ...(roleId ? { role: { id: roleId } } : {}),
2585
- ...(allowedUserIds?.length ? { allowedUsers: allowedUserIds.map((id) => ({ id })) } : {}),
2586
- };
2587
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_route_permission', {
2588
- method: 'POST',
2589
- body: JSON.stringify(body),
2590
- });
2591
- const routeReload = await reloadRoutesResult();
2592
- return { content: [{ type: 'text', text: JSON.stringify({
2593
- action: 'created',
2594
- kind: 'route_permission',
2595
- route: route.path,
2596
- routeReload,
2597
- result,
2598
- }, null, 2) }] };
2599
- },
2600
- );
2601
-
2602
2627
  server.tool(
2603
2628
  'audit_route_access',
2604
2629
  [
@@ -2772,74 +2797,6 @@ server.tool(
2772
2797
  },
2773
2798
  );
2774
2799
 
2775
- server.tool(
2776
- 'create_guard',
2777
- [
2778
- 'Create a metadata guard with optional rules for REST request gating.',
2779
- 'Root guards attach to one route by path/routeId or globally with isGlobal. pre_auth runs before JWT and only has IP/route context; post_auth runs after auth and can use user id.',
2780
- 'Rule types: rate_limit_by_ip, rate_limit_by_user, rate_limit_by_route, ip_whitelist, ip_blacklist. Rate limits use {"maxRequests":number,"perSeconds":number}; IP lists use {"ips":["127.0.0.1","10.0.0.0/24"]}.',
2781
- 'Do not use rate_limit_by_user or userIds on pre_auth guards. Create risky global/IP whitelist guards disabled first, then inspect and test before enabling.',
2782
- ].join(' '),
2783
- {
2784
- name: z.string().describe('Guard name'),
2785
- position: z.enum(['pre_auth', 'post_auth']).default('pre_auth').describe('Execution position for root guard. pre_auth has only IP/route context; post_auth also has authenticated user id.'),
2786
- routeId: z.union([z.string(), z.number()]).optional().describe('Optional route id'),
2787
- path: z.string().optional().describe('Optional route path'),
2788
- methods: z.array(z.string()).optional().describe('Method names this guard applies to. Empty means all configured behavior for route/global.'),
2789
- combinator: z.enum(['and', 'or']).default('and').describe('How child guards/rules combine'),
2790
- priority: z.number().optional().default(0).describe('Lower runs first'),
2791
- isGlobal: z.boolean().optional().default(false).describe('Apply globally instead of one route'),
2792
- isEnabled: z.boolean().optional().default(false).describe('Enable immediately. Default false to avoid accidental lockout.'),
2793
- description: z.string().optional().describe('Admin note'),
2794
- rules: z.string().optional().describe('Optional rules JSON array: [{type, config, priority?, isEnabled?, description?, userIds?}]. Supported types: rate_limit_by_ip, rate_limit_by_user, rate_limit_by_route, ip_whitelist, ip_blacklist.'),
2795
- },
2796
- async ({ name, position, routeId, path, methods, combinator, priority, isGlobal, isEnabled, description, rules }) => {
2797
- let route = null;
2798
- if (!isGlobal && (routeId || path)) {
2799
- const routes = await fetchAll('/enfyra_route?limit=1000');
2800
- route = routes.find((item) => (
2801
- routeId ? sameId(getId(item), routeId) : item.path === normalizeRestPath(path)
2802
- ));
2803
- if (!route) throw new Error(`Route not found: ${routeId || path}`);
2804
- }
2805
- const methodMap = await getMethodMap();
2806
- const guardBody = {
2807
- name,
2808
- position,
2809
- combinator,
2810
- priority,
2811
- isGlobal,
2812
- isEnabled,
2813
- description,
2814
- ...(route ? { route: { id: getId(route) } } : {}),
2815
- ...(methods?.length ? { methods: resolveMethodIds(methodMap, methods) } : {}),
2816
- };
2817
- const guard = await fetchAPI(ENFYRA_API_URL, '/enfyra_guard', {
2818
- method: 'POST',
2819
- body: JSON.stringify(guardBody),
2820
- });
2821
- const ruleInputs = parseJsonArg(rules, []);
2822
- const createdRules = [];
2823
- for (const rule of ruleInputs) {
2824
- const ruleBody = {
2825
- type: rule.type,
2826
- config: rule.config,
2827
- priority: rule.priority ?? 0,
2828
- isEnabled: rule.isEnabled ?? true,
2829
- description: rule.description,
2830
- guard: { id: resultRecordId(guard) },
2831
- ...(Array.isArray(rule.userIds) && rule.userIds.length ? { users: rule.userIds.map((id) => ({ id })) } : {}),
2832
- };
2833
- createdRules.push(await fetchAPI(ENFYRA_API_URL, '/enfyra_guard_rule', {
2834
- method: 'POST',
2835
- body: JSON.stringify(ruleBody),
2836
- }));
2837
- }
2838
- await fetchAPI(ENFYRA_API_URL, '/admin/reload/guards', { method: 'POST' }).catch(() => {});
2839
- return { content: [{ type: 'text', text: `Guard created. Guard cache reloaded.\n${JSON.stringify({ guard, rules: createdRules }, null, 2)}` }] };
2840
- },
2841
- );
2842
-
2843
2800
  // Register table tools
2844
2801
  registerTableTools(server, ENFYRA_API_URL);
2845
2802
  registerPlatformOperationTools(server, ENFYRA_API_URL);
@@ -2933,6 +2890,22 @@ server.tool('get_current_user', 'Get current authenticated user info', {}, async
2933
2890
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
2934
2891
  });
2935
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
+
2936
2909
  server.tool('get_all_roles', 'Get all role definitions', {}, async () => {
2937
2910
  const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_role?limit=100');
2938
2911
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };