@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 +5 -1
- package/package.json +1 -1
- package/src/lib/auth.js +44 -22
- package/src/lib/mcp-examples.js +28 -7
- package/src/lib/mcp-instructions.js +7 -2
- package/src/lib/platform-operation-tools.js +217 -3
- package/src/mcp-server-entry.mjs +172 -0
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`, `
|
|
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
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
|
-
|
|
17
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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;
|
package/src/lib/mcp-examples.js
CHANGED
|
@@ -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": "
|
|
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": "
|
|
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": "
|
|
1399
|
-
{ "key": "
|
|
1400
|
-
{ "key": "
|
|
1401
|
-
{ "key": "
|
|
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
|
|
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
|
-
'-
|
|
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.'),
|
package/src/mcp-server-entry.mjs
CHANGED
|
@@ -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) }] };
|