@enfyra/mcp-server 0.0.108 → 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 +5 -1
- package/package.json +1 -1
- package/src/lib/mcp-examples.js +25 -7
- package/src/lib/mcp-instructions.js +6 -1
- package/src/lib/platform-operation-tools.js +172 -0
- 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/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: {
|
|
@@ -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,7 +29,8 @@ 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
|
-
'-
|
|
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');
|
|
@@ -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';
|
|
@@ -1519,6 +1597,28 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
1519
1597
|
})),
|
|
1520
1598
|
);
|
|
1521
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
|
+
|
|
1522
1622
|
server.tool(
|
|
1523
1623
|
'ensure_script_flow_step',
|
|
1524
1624
|
'Business operation: create or update one script flow step. Use this for JavaScript/TypeScript flow logic instead of choosing type=script manually.',
|
|
@@ -1595,6 +1695,60 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
1595
1695
|
})),
|
|
1596
1696
|
);
|
|
1597
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
|
+
|
|
1598
1752
|
server.tool(
|
|
1599
1753
|
'ensure_sleep_flow_step',
|
|
1600
1754
|
'Business operation: create or update one sleep/wait flow step. Use this for delays instead of choosing type=sleep manually.',
|
|
@@ -1613,6 +1767,24 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
1613
1767
|
})),
|
|
1614
1768
|
);
|
|
1615
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
|
+
|
|
1616
1788
|
server.tool(
|
|
1617
1789
|
'ensure_trigger_flow_step',
|
|
1618
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.',
|
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) }] };
|