@enfyra/mcp-server 0.0.97 → 0.0.98
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 +3 -0
- package/package.json +1 -1
- package/src/lib/mcp-examples.js +13 -13
- package/src/lib/mcp-instructions.js +3 -1
- package/src/lib/platform-operation-tools.js +923 -0
- package/src/mcp-server-entry.mjs +2 -0
package/README.md
CHANGED
|
@@ -183,10 +183,13 @@ The MCP server includes safety guards for LLM callers:
|
|
|
183
183
|
|
|
184
184
|
- Generic record mutations validate fields against live metadata.
|
|
185
185
|
- Script-backed records validate `sourceCode` through `/admin/script/validate` before saving.
|
|
186
|
+
- `validate_dynamic_script` checks handler, hook, flow, websocket, GraphQL, and bootstrap script source without saving.
|
|
187
|
+
- `validate_extension_code` checks Enfyra admin extension code through `/enfyra_extension/preview` without saving.
|
|
186
188
|
- `compiledCode` is generated from `sourceCode` and may differ textually because macros are expanded; the MCP server never accepts hand-written `compiledCode`.
|
|
187
189
|
- Relation tools reject physical FK/junction names.
|
|
188
190
|
- Generated code should use relation property names such as `conversation`, `sender`, and `member` instead of physical FK fields such as `conversationId`, `senderId`, or `memberId`.
|
|
189
191
|
- Custom route tools reject `mainTableId` unless the route is the canonical table route.
|
|
192
|
+
- Platform operation tools such as `create_api_endpoint`, `set_route_public_methods`, `set_table_graphql`, `ensure_guard`, `ensure_field_permission`, `ensure_column_rule`, `ensure_websocket_event`, `ensure_flow_step`, and `ensure_menu_extension_page` resolve metadata ids and validate code before saving.
|
|
190
193
|
- Schema changes are serialized.
|
|
191
194
|
- Destructive deletes return a preview before requiring `confirm=true`.
|
|
192
195
|
|
package/package.json
CHANGED
package/src/lib/mcp-examples.js
CHANGED
|
@@ -996,22 +996,20 @@ ensure_route_access({
|
|
|
996
996
|
},
|
|
997
997
|
{
|
|
998
998
|
name: 'Publish read-only route',
|
|
999
|
-
code: `
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
publicMethods: [{ id: "<GET_method_id_from_list_methods>" }]
|
|
1004
|
-
}
|
|
999
|
+
code: `set_route_public_methods({
|
|
1000
|
+
path: "/articles",
|
|
1001
|
+
methods: ["GET"],
|
|
1002
|
+
mode: "merge"
|
|
1005
1003
|
})`,
|
|
1006
1004
|
notes: [
|
|
1007
|
-
'
|
|
1005
|
+
'Use set_route_public_methods instead of raw enfyra_route updates; the tool resolves method ids and validates that GET is available.',
|
|
1008
1006
|
'publicMethods controls anonymous route access. Route permissions are not for public access.',
|
|
1009
1007
|
'Route permissions apply when the method is not public.',
|
|
1010
1008
|
],
|
|
1011
1009
|
},
|
|
1012
1010
|
{
|
|
1013
1011
|
name: 'Rate limit anonymous requests by IP',
|
|
1014
|
-
code: `
|
|
1012
|
+
code: `ensure_guard({
|
|
1015
1013
|
name: "Public signup IP rate limit",
|
|
1016
1014
|
path: "/newsletter_signup",
|
|
1017
1015
|
methods: ["POST"],
|
|
@@ -1042,7 +1040,7 @@ test_rest_endpoint({
|
|
|
1042
1040
|
},
|
|
1043
1041
|
{
|
|
1044
1042
|
name: 'Rate limit authenticated users',
|
|
1045
|
-
code: `
|
|
1043
|
+
code: `ensure_guard({
|
|
1046
1044
|
name: "Project create per-user limit",
|
|
1047
1045
|
path: "/projects",
|
|
1048
1046
|
methods: ["POST"],
|
|
@@ -1064,7 +1062,7 @@ test_rest_endpoint({
|
|
|
1064
1062
|
},
|
|
1065
1063
|
{
|
|
1066
1064
|
name: 'Restrict an admin-only route to office IPs',
|
|
1067
|
-
code: `
|
|
1065
|
+
code: `ensure_guard({
|
|
1068
1066
|
name: "Admin reports office allowlist",
|
|
1069
1067
|
path: "/admin/reports",
|
|
1070
1068
|
methods: ["GET", "POST"],
|
|
@@ -1086,7 +1084,7 @@ test_rest_endpoint({
|
|
|
1086
1084
|
},
|
|
1087
1085
|
{
|
|
1088
1086
|
name: 'Column rule for email format',
|
|
1089
|
-
code: `
|
|
1087
|
+
code: `ensure_column_rule({
|
|
1090
1088
|
tableName: "enfyra_user",
|
|
1091
1089
|
columnName: "email",
|
|
1092
1090
|
ruleType: "format",
|
|
@@ -1101,10 +1099,12 @@ test_rest_endpoint({
|
|
|
1101
1099
|
},
|
|
1102
1100
|
{
|
|
1103
1101
|
name: 'Field permission condition',
|
|
1104
|
-
code: `
|
|
1102
|
+
code: `ensure_field_permission({
|
|
1105
1103
|
tableName: "project",
|
|
1106
|
-
|
|
1104
|
+
columnName: "internal_notes",
|
|
1107
1105
|
action: "read",
|
|
1106
|
+
effect: "allow",
|
|
1107
|
+
roleName: "user",
|
|
1108
1108
|
condition: JSON.stringify({
|
|
1109
1109
|
owner: { id: { _eq: "@USER.id" } }
|
|
1110
1110
|
})
|
|
@@ -29,6 +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
|
+
'- Prefer business operation tools over raw metadata CRUD for platform operations: `create_api_endpoint`, `ensure_route_methods`, `set_route_public_methods`, `set_table_graphql`, `ensure_guard`, `ensure_field_permission`, `ensure_column_rule`, `ensure_websocket_gateway`, `ensure_websocket_event`, `ensure_flow`, `ensure_flow_step`, and `ensure_menu_extension_page`.',
|
|
33
|
+
'- 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.',
|
|
32
34
|
'- 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.',
|
|
33
35
|
'- Validate behavior with `test_rest_endpoint`, `run_admin_test`, `test_flow_step`, or the route-specific tool before claiming a dynamic feature works.',
|
|
34
36
|
'',
|
|
@@ -40,7 +42,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
40
42
|
'- Dynamic repository reads use `filter`, not `where`: `@REPOS.table.find({ filter: {...} })`, `#table.find({ filter: {...} })`, and `exists(filter)`.',
|
|
41
43
|
'- Use `enfyra_user` as the user table. Model record links as real relations using relation `propertyName` values, not physical FK fields like `userId`, `conversationId`, `senderId`, or `memberId` in generated DB code.',
|
|
42
44
|
'- Do not call internal/no-route system tables such as `enfyra_column` or `enfyra_session` through generic CRUD. Use table/column/relation tools and route-backed tables discovered from metadata.',
|
|
43
|
-
'- Custom API paths use `create_route` without `mainTableId
|
|
45
|
+
'- Custom API paths use `create_api_endpoint` when a handler is needed. Use lower-level `create_route` without `mainTableId` only when intentionally creating a route shell; `create_table` is only for new persisted data.',
|
|
44
46
|
'- For canonical table reads and RLS, preserve client-controlled query shape: do not override `@QUERY.fields`, `@QUERY.deep`, `@QUERY.sort`, `@QUERY.limit`, `@QUERY.page`, `@QUERY.meta`, `@QUERY.aggregate`, or `debugMode`. Merge only security filters into `@QUERY.filter`.',
|
|
45
47
|
'- 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.',
|
|
46
48
|
'- For intentional user/domain errors in scripts use `@THROW400`-style helpers or `$ctx.$throw[...]`, not `throw new Error(...)`.',
|
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import { fetchAPI } from './fetch.js';
|
|
4
|
+
import { validateScriptSourceIfPresent } from './mutation-guards.js';
|
|
5
|
+
|
|
6
|
+
function unwrapData(result) {
|
|
7
|
+
return Array.isArray(result?.data) ? result.data : [];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function getId(record) {
|
|
11
|
+
return record?.id ?? record?._id ?? null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function refId(value) {
|
|
15
|
+
return typeof value === 'object' && value !== null ? getId(value) : value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sameId(a, b) {
|
|
19
|
+
if (a === null || a === undefined || b === null || b === undefined) return false;
|
|
20
|
+
return String(a) === String(b);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function firstDataRecord(result) {
|
|
24
|
+
return Array.isArray(result?.data) ? result.data[0] : result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeRestPath(path) {
|
|
28
|
+
if (!path) return '/';
|
|
29
|
+
if (/^https?:\/\//i.test(path)) {
|
|
30
|
+
throw new Error('Only Enfyra API paths are allowed, not full external URLs.');
|
|
31
|
+
}
|
|
32
|
+
return path.startsWith('/') ? path : `/${path}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeMethodName(method) {
|
|
36
|
+
const value = String(method || '').trim().toUpperCase();
|
|
37
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(value)) {
|
|
38
|
+
throw new Error(`Invalid method "${method}". Method names must start with A-Z and contain only A-Z, 0-9, or underscore.`);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function methodNamesFromRecords(records, methodIdNameMap) {
|
|
44
|
+
return (records || [])
|
|
45
|
+
.map((method) => method?.name || methodIdNameMap[String(getId(method))] || null)
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.map(normalizeMethodName);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function uniqueMethodNames(names) {
|
|
51
|
+
return [...new Set((names || []).map(normalizeMethodName))];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveMethodRefs(methodMap, names) {
|
|
55
|
+
return uniqueMethodNames(names).map((name) => {
|
|
56
|
+
const id = methodMap[name];
|
|
57
|
+
if (!id) throw new Error(`Unknown method "${name}". Valid methods: ${Object.keys(methodMap).sort().join(', ')}`);
|
|
58
|
+
return { id };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mergeMethods(existing, requested, mode) {
|
|
63
|
+
const existingNames = uniqueMethodNames(existing);
|
|
64
|
+
const requestedNames = uniqueMethodNames(requested);
|
|
65
|
+
if (mode === 'replace') return requestedNames;
|
|
66
|
+
if (mode === 'remove') return existingNames.filter((method) => !requestedNames.includes(method));
|
|
67
|
+
return uniqueMethodNames([...existingNames, ...requestedNames]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function fetchAll(apiUrl, path) {
|
|
71
|
+
return unwrapData(await fetchAPI(apiUrl, path));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function getMethodContext(apiUrl) {
|
|
75
|
+
const methods = await fetchAll(apiUrl, '/enfyra_method?limit=0&fields=id,_id,name');
|
|
76
|
+
const methodMap = {};
|
|
77
|
+
const methodIdNameMap = {};
|
|
78
|
+
for (const method of methods) {
|
|
79
|
+
if (!method?.name) continue;
|
|
80
|
+
const name = normalizeMethodName(method.name);
|
|
81
|
+
const id = getId(method);
|
|
82
|
+
methodMap[name] = id;
|
|
83
|
+
methodIdNameMap[String(id)] = name;
|
|
84
|
+
}
|
|
85
|
+
return { methods, methodMap, methodIdNameMap };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function reloadRoutes(apiUrl) {
|
|
89
|
+
try {
|
|
90
|
+
const result = await fetchAPI(apiUrl, '/admin/reload/routes', { method: 'POST' });
|
|
91
|
+
return { attempted: true, succeeded: true, result };
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return { attempted: true, succeeded: false, error: error?.message || String(error) };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function resolveRoute(apiUrl, { path, routeId }) {
|
|
98
|
+
if (!path && !routeId) throw new Error('Provide path or routeId.');
|
|
99
|
+
if (path && routeId) throw new Error('Provide path or routeId, not both.');
|
|
100
|
+
const routes = await fetchAll(apiUrl, '/enfyra_route?limit=1000&fields=id,_id,path,isEnabled,availableMethods.*,publicMethods.*,mainTable.name');
|
|
101
|
+
const normalizedPath = path ? normalizeRestPath(path) : null;
|
|
102
|
+
const route = routes.find((item) => (routeId ? sameId(getId(item), routeId) : item.path === normalizedPath));
|
|
103
|
+
if (!route) throw new Error(`Route not found: ${routeId || normalizedPath}`);
|
|
104
|
+
return { route, routes, path: route.path };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function findHandler(apiUrl, routeId, methodId) {
|
|
108
|
+
const filter = encodeURIComponent(JSON.stringify({
|
|
109
|
+
route: { id: { _eq: routeId } },
|
|
110
|
+
method: { id: { _eq: methodId } },
|
|
111
|
+
}));
|
|
112
|
+
const result = await fetchAPI(apiUrl, `/enfyra_route_handler?filter=${filter}&limit=1&fields=id,_id,route.id,method.id,method.name,sourceCode,scriptLanguage,timeout`);
|
|
113
|
+
return unwrapData(result)[0] || null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function jsonText(payload) {
|
|
117
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseJsonObjectArg(name, value, fallback = {}) {
|
|
121
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
122
|
+
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
|
123
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
124
|
+
throw new Error(`${name} must be a JSON object.`);
|
|
125
|
+
}
|
|
126
|
+
return parsed;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseJsonArrayArg(name, value, fallback = []) {
|
|
130
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
131
|
+
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
|
132
|
+
if (!Array.isArray(parsed)) {
|
|
133
|
+
throw new Error(`${name} must be a JSON array.`);
|
|
134
|
+
}
|
|
135
|
+
return parsed;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function filterQuery(filter) {
|
|
139
|
+
return encodeURIComponent(JSON.stringify(filter));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function reloadBestEffort(apiUrl, path) {
|
|
143
|
+
try {
|
|
144
|
+
const result = await fetchAPI(apiUrl, path, { method: 'POST' });
|
|
145
|
+
return { attempted: true, succeeded: true, result };
|
|
146
|
+
} catch (error) {
|
|
147
|
+
return { attempted: true, succeeded: false, error: error?.message || String(error) };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function validateDynamicScript(apiUrl, sourceCode, scriptLanguage = 'javascript') {
|
|
152
|
+
const result = await fetchAPI(apiUrl, '/admin/script/validate', {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
body: JSON.stringify({ sourceCode, scriptLanguage }),
|
|
155
|
+
});
|
|
156
|
+
if (result?.valid === false || result?.success === false) {
|
|
157
|
+
throw new Error(result?.error?.message || 'Dynamic script validation failed.');
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
valid: true,
|
|
161
|
+
scriptLanguage,
|
|
162
|
+
compiledLength: typeof result?.data?.compiledCode === 'string' ? result.data.compiledCode.length : undefined,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function validateExtensionCode(apiUrl, code, name) {
|
|
167
|
+
const result = await fetchAPI(apiUrl, '/enfyra_extension/preview', {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
body: JSON.stringify({ code, name }),
|
|
170
|
+
});
|
|
171
|
+
if (result?.success === false) {
|
|
172
|
+
throw new Error(result?.error?.message || 'Extension validation failed.');
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
valid: true,
|
|
176
|
+
extensionId: result?.extensionId || name || null,
|
|
177
|
+
compiledLength: typeof result?.compiledCode === 'string' ? result.compiledCode.length : undefined,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function normalizeMetadataTables(metadata) {
|
|
182
|
+
const tables = metadata?.data?.tables || metadata?.tables || metadata?.data || [];
|
|
183
|
+
return Array.isArray(tables) ? tables : Object.values(tables || {});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function getMetadataTables(apiUrl) {
|
|
187
|
+
return normalizeMetadataTables(await fetchAPI(apiUrl, '/metadata'));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function resolveTable(tables, tableName) {
|
|
191
|
+
const table = tables.find((item) => item?.name === tableName || item?.alias === tableName || sameId(getId(item), tableName));
|
|
192
|
+
if (!table) throw new Error(`Table not found: ${tableName}`);
|
|
193
|
+
return table;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function resolveColumn(table, columnName) {
|
|
197
|
+
const column = (table.columns || []).find((item) => item?.name === columnName || sameId(getId(item), columnName));
|
|
198
|
+
if (!column) throw new Error(`Column not found: ${table.name}.${columnName}`);
|
|
199
|
+
return column;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function resolveRelation(table, relationName) {
|
|
203
|
+
const relation = (table.relations || []).find((item) => item?.propertyName === relationName || item?.name === relationName || sameId(getId(item), relationName));
|
|
204
|
+
if (!relation) throw new Error(`Relation not found: ${table.name}.${relationName}`);
|
|
205
|
+
return relation;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function findRecord(apiUrl, tableName, filter, fields = '*') {
|
|
209
|
+
const result = await fetchAPI(apiUrl, `/${tableName}?filter=${filterQuery(filter)}&limit=1&fields=${encodeURIComponent(fields)}`);
|
|
210
|
+
return unwrapData(result)[0] || null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function fetchRecords(apiUrl, tableName, filter, fields = '*', limit = 1000) {
|
|
214
|
+
const result = await fetchAPI(apiUrl, `/${tableName}?filter=${filterQuery(filter)}&limit=${limit}&fields=${encodeURIComponent(fields)}`);
|
|
215
|
+
return unwrapData(result);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function createOrPatch(apiUrl, tableName, existing, body) {
|
|
219
|
+
if (existing) {
|
|
220
|
+
const result = await fetchAPI(apiUrl, `/${tableName}/${encodeURIComponent(String(getId(existing)))}`, {
|
|
221
|
+
method: 'PATCH',
|
|
222
|
+
body: JSON.stringify(body),
|
|
223
|
+
});
|
|
224
|
+
return { action: 'updated', result, id: getId(firstDataRecord(result)) || getId(existing) };
|
|
225
|
+
}
|
|
226
|
+
const result = await fetchAPI(apiUrl, `/${tableName}`, {
|
|
227
|
+
method: 'POST',
|
|
228
|
+
body: JSON.stringify(body),
|
|
229
|
+
});
|
|
230
|
+
return { action: 'created', result, id: getId(firstDataRecord(result)) };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function resolveRole(apiUrl, { roleId, roleName }) {
|
|
234
|
+
if (roleId && roleName) throw new Error('Provide roleId or roleName, not both.');
|
|
235
|
+
if (!roleId && !roleName) return null;
|
|
236
|
+
if (roleId) return { id: roleId, name: null };
|
|
237
|
+
const role = await findRecord(apiUrl, 'enfyra_role', { name: { _eq: roleName } }, 'id,_id,name');
|
|
238
|
+
if (!role) throw new Error(`Role not found: ${roleName}`);
|
|
239
|
+
return { id: getId(role), name: role.name };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function assertOneScope({ roleId, roleName, allowedUserIds }) {
|
|
243
|
+
if (!roleId && !roleName && (!allowedUserIds || allowedUserIds.length === 0)) {
|
|
244
|
+
throw new Error('Provide roleId, roleName, or allowedUserIds.');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function normalizeFlowStepBody(step, flowId) {
|
|
249
|
+
const body = {
|
|
250
|
+
key: step.key,
|
|
251
|
+
type: step.type,
|
|
252
|
+
order: step.order ?? 0,
|
|
253
|
+
config: step.config ?? {},
|
|
254
|
+
timeout: step.timeout,
|
|
255
|
+
isEnabled: step.isEnabled ?? true,
|
|
256
|
+
description: step.description,
|
|
257
|
+
flow: { id: flowId },
|
|
258
|
+
};
|
|
259
|
+
if (step.sourceCode !== undefined) body.sourceCode = step.sourceCode;
|
|
260
|
+
if (step.scriptLanguage !== undefined) body.scriptLanguage = step.scriptLanguage;
|
|
261
|
+
return Object.fromEntries(Object.entries(body).filter(([, value]) => value !== undefined));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
265
|
+
server.tool(
|
|
266
|
+
'validate_dynamic_script',
|
|
267
|
+
[
|
|
268
|
+
'Validate Enfyra dynamic script code before saving it to any script-backed metadata record.',
|
|
269
|
+
'Use this before create/update of handlers, hooks, flow steps, websocket scripts, GraphQL scripts, or bootstrap scripts when the user is iterating on code.',
|
|
270
|
+
'This calls the same server compiler contract used by Enfyra, but does not save anything.',
|
|
271
|
+
].join(' '),
|
|
272
|
+
{
|
|
273
|
+
sourceCode: z.string().describe('Raw dynamic script sourceCode.'),
|
|
274
|
+
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language to validate.'),
|
|
275
|
+
},
|
|
276
|
+
async ({ sourceCode, scriptLanguage }) => jsonText({
|
|
277
|
+
action: 'dynamic_script_validated',
|
|
278
|
+
validation: await validateDynamicScript(ENFYRA_API_URL, sourceCode, scriptLanguage),
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
server.tool(
|
|
283
|
+
'validate_extension_code',
|
|
284
|
+
[
|
|
285
|
+
'Validate Enfyra admin extension code before saving it to enfyra_extension.',
|
|
286
|
+
'Use this for Vue SFC page/widget/global extension code. It calls /enfyra_extension/preview and does not save anything.',
|
|
287
|
+
].join(' '),
|
|
288
|
+
{
|
|
289
|
+
code: z.string().describe('Vue SFC or compiled extension bundle code.'),
|
|
290
|
+
name: z.string().optional().describe('Optional extension name/id used by the preview compiler.'),
|
|
291
|
+
},
|
|
292
|
+
async ({ code, name }) => jsonText({
|
|
293
|
+
action: 'extension_code_validated',
|
|
294
|
+
validation: await validateExtensionCode(ENFYRA_API_URL, code, name),
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
server.tool(
|
|
299
|
+
'set_table_graphql',
|
|
300
|
+
'Business operation: enable or disable GraphQL for one table through enfyra_graphql, then reload GraphQL. REST route methods do not control GraphQL.',
|
|
301
|
+
{
|
|
302
|
+
tableName: z.string().describe('Table name, alias, or id.'),
|
|
303
|
+
isEnabled: z.boolean().describe('Desired GraphQL enabled state for the table.'),
|
|
304
|
+
},
|
|
305
|
+
async ({ tableName, isEnabled }) => {
|
|
306
|
+
const table = resolveTable(await getMetadataTables(ENFYRA_API_URL), tableName);
|
|
307
|
+
const existing = await findRecord(ENFYRA_API_URL, 'enfyra_graphql', { table: { id: { _eq: getId(table) } } }, 'id,_id,table.id,isEnabled');
|
|
308
|
+
const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_graphql', existing, {
|
|
309
|
+
table: { id: getId(table) },
|
|
310
|
+
isEnabled,
|
|
311
|
+
});
|
|
312
|
+
const graphqlReload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/graphql');
|
|
313
|
+
return jsonText({
|
|
314
|
+
action: 'table_graphql_set',
|
|
315
|
+
table: { id: getId(table), name: table.name },
|
|
316
|
+
graphql: { id: operation.id, isEnabled },
|
|
317
|
+
operation,
|
|
318
|
+
graphqlReload,
|
|
319
|
+
});
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
server.tool(
|
|
324
|
+
'ensure_route_methods',
|
|
325
|
+
[
|
|
326
|
+
'Business operation: set which HTTP methods a route supports.',
|
|
327
|
+
'Use this instead of raw enfyra_route CRUD when adding/removing availableMethods on an existing route.',
|
|
328
|
+
'It resolves method ids, preserves route metadata, patches availableMethods, and reloads routes.',
|
|
329
|
+
].join(' '),
|
|
330
|
+
{
|
|
331
|
+
path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
|
|
332
|
+
routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
|
|
333
|
+
methods: z.array(z.string()).min(1).describe('HTTP method names to merge, replace, or remove.'),
|
|
334
|
+
mode: z.enum(['merge', 'replace', 'remove']).optional().default('merge').describe('merge adds methods; replace sets exactly these methods; remove deletes these methods.'),
|
|
335
|
+
isEnabled: z.boolean().optional().describe('Optionally enable/disable the route in the same safe patch.'),
|
|
336
|
+
},
|
|
337
|
+
async ({ path, routeId, methods, mode, isEnabled }) => {
|
|
338
|
+
const [{ route }, { methodMap, methodIdNameMap }] = await Promise.all([
|
|
339
|
+
resolveRoute(ENFYRA_API_URL, { path, routeId }),
|
|
340
|
+
getMethodContext(ENFYRA_API_URL),
|
|
341
|
+
]);
|
|
342
|
+
const existingAvailable = methodNamesFromRecords(route.availableMethods, methodIdNameMap);
|
|
343
|
+
const existingPublic = methodNamesFromRecords(route.publicMethods, methodIdNameMap);
|
|
344
|
+
const finalAvailable = mergeMethods(existingAvailable, methods, mode);
|
|
345
|
+
const finalPublic = existingPublic.filter((method) => finalAvailable.includes(method));
|
|
346
|
+
const body = {
|
|
347
|
+
availableMethods: resolveMethodRefs(methodMap, finalAvailable),
|
|
348
|
+
publicMethods: resolveMethodRefs(methodMap, finalPublic),
|
|
349
|
+
};
|
|
350
|
+
if (isEnabled !== undefined) body.isEnabled = isEnabled;
|
|
351
|
+
|
|
352
|
+
const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, {
|
|
353
|
+
method: 'PATCH',
|
|
354
|
+
body: JSON.stringify(body),
|
|
355
|
+
});
|
|
356
|
+
const routeReload = await reloadRoutes(ENFYRA_API_URL);
|
|
357
|
+
return {
|
|
358
|
+
content: [{
|
|
359
|
+
type: 'text',
|
|
360
|
+
text: JSON.stringify({
|
|
361
|
+
action: 'route_methods_updated',
|
|
362
|
+
route: { id: getId(route), path: route.path },
|
|
363
|
+
before: { availableMethods: existingAvailable, publicMethods: existingPublic },
|
|
364
|
+
after: { availableMethods: finalAvailable, publicMethods: finalPublic },
|
|
365
|
+
result,
|
|
366
|
+
routeReload,
|
|
367
|
+
}, null, 2),
|
|
368
|
+
}],
|
|
369
|
+
};
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
server.tool(
|
|
374
|
+
'set_route_public_methods',
|
|
375
|
+
[
|
|
376
|
+
'Business operation: publish or unpublish REST methods on a route.',
|
|
377
|
+
'Use this instead of raw enfyra_route CRUD when the user says a route/method should be public, anonymous, private, or not require login.',
|
|
378
|
+
'The tool only touches publicMethods, validates that requested methods are already available on the route, and reloads routes.',
|
|
379
|
+
].join(' '),
|
|
380
|
+
{
|
|
381
|
+
path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
|
|
382
|
+
routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
|
|
383
|
+
methods: z.array(z.string()).min(1).describe('HTTP method names to publish, replace, or remove from publicMethods.'),
|
|
384
|
+
mode: z.enum(['merge', 'replace', 'remove']).optional().default('merge').describe('merge publishes methods; replace sets publicMethods exactly; remove makes these methods non-public.'),
|
|
385
|
+
},
|
|
386
|
+
async ({ path, routeId, methods, mode }) => {
|
|
387
|
+
const [{ route }, { methodMap, methodIdNameMap }] = await Promise.all([
|
|
388
|
+
resolveRoute(ENFYRA_API_URL, { path, routeId }),
|
|
389
|
+
getMethodContext(ENFYRA_API_URL),
|
|
390
|
+
]);
|
|
391
|
+
const availableMethods = methodNamesFromRecords(route.availableMethods, methodIdNameMap);
|
|
392
|
+
const existingPublic = methodNamesFromRecords(route.publicMethods, methodIdNameMap);
|
|
393
|
+
const requestedMethods = uniqueMethodNames(methods);
|
|
394
|
+
const unavailable = requestedMethods.filter((method) => !availableMethods.includes(method));
|
|
395
|
+
if (unavailable.length > 0) {
|
|
396
|
+
throw new Error(`Cannot make unavailable route method(s) public: ${unavailable.join(', ')}. First call ensure_route_methods to add them to availableMethods.`);
|
|
397
|
+
}
|
|
398
|
+
const finalPublic = mergeMethods(existingPublic, requestedMethods, mode);
|
|
399
|
+
const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, {
|
|
400
|
+
method: 'PATCH',
|
|
401
|
+
body: JSON.stringify({ publicMethods: resolveMethodRefs(methodMap, finalPublic) }),
|
|
402
|
+
});
|
|
403
|
+
const routeReload = await reloadRoutes(ENFYRA_API_URL);
|
|
404
|
+
return {
|
|
405
|
+
content: [{
|
|
406
|
+
type: 'text',
|
|
407
|
+
text: JSON.stringify({
|
|
408
|
+
action: 'route_public_methods_updated',
|
|
409
|
+
route: { id: getId(route), path: route.path },
|
|
410
|
+
availableMethods,
|
|
411
|
+
publicMethodsBefore: existingPublic,
|
|
412
|
+
publicMethodsAfter: finalPublic,
|
|
413
|
+
publicAccess: finalPublic.length > 0 ? 'Methods listed in publicMethods bypass auth/RoleGuard.' : 'No public methods remain on this route.',
|
|
414
|
+
result,
|
|
415
|
+
routeReload,
|
|
416
|
+
}, null, 2),
|
|
417
|
+
}],
|
|
418
|
+
};
|
|
419
|
+
},
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
server.tool(
|
|
423
|
+
'create_api_endpoint',
|
|
424
|
+
[
|
|
425
|
+
'Business operation: create or update a custom REST endpoint with a handler in one safe operation.',
|
|
426
|
+
'Use this when the user asks for a new route/endpoint/API path that computes or orchestrates behavior, such as GET /sum or POST /webhook.',
|
|
427
|
+
'It creates the route without mainTableId, ensures the method is available, validates sourceCode, creates or overwrites the route handler, optionally publishes the method, reloads routes, and can smoke-test the endpoint.',
|
|
428
|
+
'Use table/schema tools separately when the user needs persisted data. This tool is for custom behavior endpoints.',
|
|
429
|
+
].join(' '),
|
|
430
|
+
{
|
|
431
|
+
path: z.string().describe('Custom route path, e.g. /sum. Must not be a full URL.'),
|
|
432
|
+
method: z.string().describe('HTTP method for the handler, e.g. GET or POST.'),
|
|
433
|
+
sourceCode: z.string().describe('Handler sourceCode. Use macros such as @QUERY, @BODY, @THROW400, @REPOS, @USER. Do not send compiledCode.'),
|
|
434
|
+
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
|
|
435
|
+
public: z.boolean().optional().default(false).describe('When true, the method is added to publicMethods for anonymous access.'),
|
|
436
|
+
description: z.string().optional().describe('Route description.'),
|
|
437
|
+
timeout: z.number().int().positive().optional().describe('Optional handler timeout in ms.'),
|
|
438
|
+
overwrite: z.boolean().optional().default(false).describe('If a handler already exists for route+method, false fails; true updates its sourceCode.'),
|
|
439
|
+
smokeTestQuery: z.string().optional().describe('Optional query JSON object for a smoke test after save, e.g. {"a":"1","b":"2"}.'),
|
|
440
|
+
smokeTestBody: z.string().optional().describe('Optional body JSON object for a smoke test after save.'),
|
|
441
|
+
},
|
|
442
|
+
async ({ path, method, sourceCode, scriptLanguage, public: makePublic, description, timeout, overwrite, smokeTestQuery, smokeTestBody }) => {
|
|
443
|
+
const normalizedPath = normalizeRestPath(path);
|
|
444
|
+
const methodName = normalizeMethodName(method);
|
|
445
|
+
const { methodMap, methodIdNameMap } = await getMethodContext(ENFYRA_API_URL);
|
|
446
|
+
const methodId = methodMap[methodName];
|
|
447
|
+
if (!methodId) throw new Error(`Unknown method "${methodName}". Valid methods: ${Object.keys(methodMap).sort().join(', ')}`);
|
|
448
|
+
|
|
449
|
+
const routes = await fetchAll(ENFYRA_API_URL, '/enfyra_route?limit=1000&fields=id,_id,path,isEnabled,availableMethods.*,publicMethods.*,mainTable.name');
|
|
450
|
+
let route = routes.find((item) => item.path === normalizedPath);
|
|
451
|
+
let routeAction = 'existing';
|
|
452
|
+
if (!route) {
|
|
453
|
+
const createRouteResult = await fetchAPI(ENFYRA_API_URL, '/enfyra_route', {
|
|
454
|
+
method: 'POST',
|
|
455
|
+
body: JSON.stringify({
|
|
456
|
+
path: normalizedPath,
|
|
457
|
+
description,
|
|
458
|
+
isEnabled: true,
|
|
459
|
+
availableMethods: [{ id: methodId }],
|
|
460
|
+
publicMethods: makePublic ? [{ id: methodId }] : [],
|
|
461
|
+
}),
|
|
462
|
+
});
|
|
463
|
+
route = firstDataRecord(createRouteResult);
|
|
464
|
+
routeAction = 'created';
|
|
465
|
+
} else {
|
|
466
|
+
const availableMethods = methodNamesFromRecords(route.availableMethods, methodIdNameMap);
|
|
467
|
+
const publicMethods = methodNamesFromRecords(route.publicMethods, methodIdNameMap);
|
|
468
|
+
const finalAvailable = uniqueMethodNames([...availableMethods, methodName]);
|
|
469
|
+
const finalPublic = makePublic ? uniqueMethodNames([...publicMethods, methodName]) : publicMethods;
|
|
470
|
+
const patchRouteResult = await fetchAPI(ENFYRA_API_URL, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, {
|
|
471
|
+
method: 'PATCH',
|
|
472
|
+
body: JSON.stringify({
|
|
473
|
+
availableMethods: resolveMethodRefs(methodMap, finalAvailable),
|
|
474
|
+
publicMethods: resolveMethodRefs(methodMap, finalPublic.filter((item) => finalAvailable.includes(item))),
|
|
475
|
+
...(description !== undefined ? { description } : {}),
|
|
476
|
+
}),
|
|
477
|
+
});
|
|
478
|
+
route = firstDataRecord(patchRouteResult) || route;
|
|
479
|
+
routeAction = 'updated';
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const routeId = getId(route);
|
|
483
|
+
const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'enfyra_route_handler', {
|
|
484
|
+
sourceCode,
|
|
485
|
+
scriptLanguage,
|
|
486
|
+
});
|
|
487
|
+
const existingHandler = await findHandler(ENFYRA_API_URL, routeId, methodId);
|
|
488
|
+
let handlerResult;
|
|
489
|
+
let handlerAction;
|
|
490
|
+
if (existingHandler) {
|
|
491
|
+
if (!overwrite) {
|
|
492
|
+
throw new Error(`Handler already exists for ${methodName} ${normalizedPath} with id ${getId(existingHandler)}. Re-run with overwrite=true to update it.`);
|
|
493
|
+
}
|
|
494
|
+
handlerAction = 'updated';
|
|
495
|
+
const body = { sourceCode, scriptLanguage };
|
|
496
|
+
if (timeout !== undefined) body.timeout = timeout;
|
|
497
|
+
handlerResult = await fetchAPI(ENFYRA_API_URL, `/enfyra_route_handler/${encodeURIComponent(String(getId(existingHandler)))}`, {
|
|
498
|
+
method: 'PATCH',
|
|
499
|
+
body: JSON.stringify(body),
|
|
500
|
+
});
|
|
501
|
+
} else {
|
|
502
|
+
handlerAction = 'created';
|
|
503
|
+
const body = {
|
|
504
|
+
route: { id: routeId },
|
|
505
|
+
method: { id: methodId },
|
|
506
|
+
sourceCode,
|
|
507
|
+
scriptLanguage,
|
|
508
|
+
};
|
|
509
|
+
if (timeout !== undefined) body.timeout = timeout;
|
|
510
|
+
handlerResult = await fetchAPI(ENFYRA_API_URL, '/enfyra_route_handler', {
|
|
511
|
+
method: 'POST',
|
|
512
|
+
body: JSON.stringify(body),
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const routeReload = await reloadRoutes(ENFYRA_API_URL);
|
|
517
|
+
let smokeTest = null;
|
|
518
|
+
if (smokeTestQuery !== undefined || smokeTestBody !== undefined) {
|
|
519
|
+
const query = smokeTestQuery ? JSON.parse(smokeTestQuery) : {};
|
|
520
|
+
if (!query || typeof query !== 'object' || Array.isArray(query)) throw new Error('smokeTestQuery must be a JSON object.');
|
|
521
|
+
const queryParams = new URLSearchParams();
|
|
522
|
+
for (const [key, value] of Object.entries(query)) {
|
|
523
|
+
if (value !== undefined && value !== null) queryParams.set(key, String(value));
|
|
524
|
+
}
|
|
525
|
+
const body = smokeTestBody ? JSON.parse(smokeTestBody) : undefined;
|
|
526
|
+
const smokePath = `${normalizedPath}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
|
527
|
+
smokeTest = await fetchAPI(ENFYRA_API_URL, smokePath, {
|
|
528
|
+
method: methodName,
|
|
529
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const savedHandler = firstDataRecord(handlerResult);
|
|
534
|
+
return {
|
|
535
|
+
content: [{
|
|
536
|
+
type: 'text',
|
|
537
|
+
text: JSON.stringify({
|
|
538
|
+
action: 'api_endpoint_ready',
|
|
539
|
+
endpoint: {
|
|
540
|
+
path: normalizedPath,
|
|
541
|
+
method: methodName,
|
|
542
|
+
public: makePublic,
|
|
543
|
+
routeId,
|
|
544
|
+
handlerId: getId(savedHandler) || getId(existingHandler),
|
|
545
|
+
},
|
|
546
|
+
routeAction,
|
|
547
|
+
handlerAction,
|
|
548
|
+
scriptValidation,
|
|
549
|
+
routeReload,
|
|
550
|
+
smokeTest,
|
|
551
|
+
usage: {
|
|
552
|
+
restPath: `${ENFYRA_API_URL.replace(/\/$/, '')}${normalizedPath}`,
|
|
553
|
+
auth: makePublic ? 'anonymous allowed for this method' : 'Bearer auth and route access are required unless another guard bypass applies',
|
|
554
|
+
},
|
|
555
|
+
}, null, 2),
|
|
556
|
+
}],
|
|
557
|
+
};
|
|
558
|
+
},
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
server.tool(
|
|
562
|
+
'ensure_column_rule',
|
|
563
|
+
'Business operation: create or update a column validation rule. It resolves table/column ids and avoids duplicate rules for the same column+ruleType.',
|
|
564
|
+
{
|
|
565
|
+
tableName: z.string().describe('Table name, alias, or id.'),
|
|
566
|
+
columnName: z.string().describe('Column name or id.'),
|
|
567
|
+
ruleType: z.enum(['min', 'max', 'minLength', 'maxLength', 'pattern', 'format', 'minItems', 'maxItems', 'custom']).describe('Validation rule type.'),
|
|
568
|
+
value: z.string().optional().describe('Rule config JSON object, usually {"v": ...}.'),
|
|
569
|
+
message: z.string().optional().describe('Custom validation error message.'),
|
|
570
|
+
description: z.string().optional().describe('Admin note.'),
|
|
571
|
+
isEnabled: z.boolean().optional().default(true).describe('Enable the rule.'),
|
|
572
|
+
},
|
|
573
|
+
async ({ tableName, columnName, ruleType, value, message, description, isEnabled }) => {
|
|
574
|
+
const table = resolveTable(await getMetadataTables(ENFYRA_API_URL), tableName);
|
|
575
|
+
const column = resolveColumn(table, columnName);
|
|
576
|
+
const existing = await findRecord(ENFYRA_API_URL, 'enfyra_column_rule', {
|
|
577
|
+
column: { id: { _eq: getId(column) } },
|
|
578
|
+
ruleType: { _eq: ruleType },
|
|
579
|
+
}, 'id,_id,column.id,ruleType');
|
|
580
|
+
const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_column_rule', existing, {
|
|
581
|
+
column: { id: getId(column) },
|
|
582
|
+
ruleType,
|
|
583
|
+
value: parseJsonObjectArg('value', value, null),
|
|
584
|
+
message,
|
|
585
|
+
description,
|
|
586
|
+
isEnabled,
|
|
587
|
+
});
|
|
588
|
+
return jsonText({
|
|
589
|
+
action: 'column_rule_ensured',
|
|
590
|
+
table: { id: getId(table), name: table.name },
|
|
591
|
+
column: { id: getId(column), name: column.name },
|
|
592
|
+
ruleType,
|
|
593
|
+
operation,
|
|
594
|
+
});
|
|
595
|
+
},
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
server.tool(
|
|
599
|
+
'ensure_field_permission',
|
|
600
|
+
'Business operation: create or update one field permission. It resolves table field ids, enforces exactly one column/relation target, and enforces a role/user scope.',
|
|
601
|
+
{
|
|
602
|
+
tableName: z.string().describe('Table name, alias, or id.'),
|
|
603
|
+
columnName: z.string().optional().describe('Column name/id to protect. Use exactly one of columnName or relationName.'),
|
|
604
|
+
relationName: z.string().optional().describe('Relation propertyName/id to protect. Use exactly one of columnName or relationName.'),
|
|
605
|
+
action: z.enum(['read', 'create', 'update']).optional().default('read').describe('Field action.'),
|
|
606
|
+
effect: z.enum(['allow', 'deny']).optional().default('allow').describe('Permission effect.'),
|
|
607
|
+
roleId: z.union([z.string(), z.number()]).optional().describe('Role id scope.'),
|
|
608
|
+
roleName: z.string().optional().describe('Role name scope.'),
|
|
609
|
+
allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Direct user id scope.'),
|
|
610
|
+
condition: z.string().optional().describe('Condition JSON object using field permission DSL.'),
|
|
611
|
+
description: z.string().optional().describe('Admin note.'),
|
|
612
|
+
isEnabled: z.boolean().optional().default(true).describe('Enable the permission.'),
|
|
613
|
+
},
|
|
614
|
+
async ({ tableName, columnName, relationName, action, effect, roleId, roleName, allowedUserIds, condition, description, isEnabled }) => {
|
|
615
|
+
if (!!columnName === !!relationName) throw new Error('Provide exactly one of columnName or relationName.');
|
|
616
|
+
assertOneScope({ roleId, roleName, allowedUserIds });
|
|
617
|
+
const [tables, role] = await Promise.all([
|
|
618
|
+
getMetadataTables(ENFYRA_API_URL),
|
|
619
|
+
resolveRole(ENFYRA_API_URL, { roleId, roleName }),
|
|
620
|
+
]);
|
|
621
|
+
const table = resolveTable(tables, tableName);
|
|
622
|
+
const field = columnName ? resolveColumn(table, columnName) : resolveRelation(table, relationName);
|
|
623
|
+
const filter = {
|
|
624
|
+
action: { _eq: action },
|
|
625
|
+
effect: { _eq: effect },
|
|
626
|
+
...(columnName ? { column: { id: { _eq: getId(field) } } } : { relation: { id: { _eq: getId(field) } } }),
|
|
627
|
+
...(role ? { role: { id: { _eq: role.id } } } : {}),
|
|
628
|
+
};
|
|
629
|
+
const existing = role
|
|
630
|
+
? await findRecord(ENFYRA_API_URL, 'enfyra_field_permission', filter, 'id,_id,column.id,relation.id,role.id,action,effect')
|
|
631
|
+
: null;
|
|
632
|
+
const body = {
|
|
633
|
+
action,
|
|
634
|
+
effect,
|
|
635
|
+
isEnabled,
|
|
636
|
+
description,
|
|
637
|
+
condition: parseJsonObjectArg('condition', condition, null),
|
|
638
|
+
...(columnName ? { column: { id: getId(field) } } : { relation: { id: getId(field) } }),
|
|
639
|
+
...(role ? { role: { id: role.id } } : {}),
|
|
640
|
+
...(allowedUserIds?.length ? { allowedUsers: allowedUserIds.map((id) => ({ id })) } : {}),
|
|
641
|
+
};
|
|
642
|
+
const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_field_permission', existing, body);
|
|
643
|
+
const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/metadata');
|
|
644
|
+
return jsonText({
|
|
645
|
+
action: 'field_permission_ensured',
|
|
646
|
+
table: { id: getId(table), name: table.name },
|
|
647
|
+
field: { id: getId(field), name: columnName ? field.name : field.propertyName, kind: columnName ? 'column' : 'relation' },
|
|
648
|
+
scope: { role, allowedUserIds: allowedUserIds || [] },
|
|
649
|
+
operation,
|
|
650
|
+
reload,
|
|
651
|
+
});
|
|
652
|
+
},
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
server.tool(
|
|
656
|
+
'ensure_guard',
|
|
657
|
+
'Business operation: create or update a request guard and optional guard rules. It resolves route/method ids and prevents pre_auth user-based rules.',
|
|
658
|
+
{
|
|
659
|
+
name: z.string().describe('Guard name. Existing guard with this name is updated unless guardId is provided.'),
|
|
660
|
+
guardId: z.union([z.string(), z.number()]).optional().describe('Optional existing guard id.'),
|
|
661
|
+
position: z.enum(['pre_auth', 'post_auth']).optional().default('pre_auth').describe('Guard position.'),
|
|
662
|
+
routeId: z.union([z.string(), z.number()]).optional().describe('Optional route id.'),
|
|
663
|
+
path: z.string().optional().describe('Optional route path.'),
|
|
664
|
+
methods: z.array(z.string()).optional().describe('HTTP method names.'),
|
|
665
|
+
combinator: z.enum(['and', 'or']).optional().default('and').describe('Rule combinator.'),
|
|
666
|
+
priority: z.number().optional().default(0).describe('Lower runs earlier.'),
|
|
667
|
+
isGlobal: z.boolean().optional().default(false).describe('Apply globally.'),
|
|
668
|
+
isEnabled: z.boolean().optional().default(false).describe('Enable guard. Defaults false to avoid lockout.'),
|
|
669
|
+
description: z.string().optional().describe('Admin note.'),
|
|
670
|
+
rules: z.string().optional().describe('Rules JSON array: [{type, config, priority, isEnabled, description, userIds}].'),
|
|
671
|
+
rulesMode: z.enum(['append', 'replace', 'none']).optional().default('append').describe('append creates rules, replace disables existing rules first, none leaves rules unchanged.'),
|
|
672
|
+
},
|
|
673
|
+
async ({ name, guardId, position, routeId, path, methods, combinator, priority, isGlobal, isEnabled, description, rules, rulesMode }) => {
|
|
674
|
+
if (path && routeId) throw new Error('Provide path or routeId, not both.');
|
|
675
|
+
const ruleInputs = parseJsonArrayArg('rules', rules, []);
|
|
676
|
+
if (position === 'pre_auth') {
|
|
677
|
+
const invalid = ruleInputs.filter((rule) => rule.type === 'rate_limit_by_user' || (Array.isArray(rule.userIds) && rule.userIds.length));
|
|
678
|
+
if (invalid.length) throw new Error('pre_auth guards cannot use user-based rules or userIds. Use post_auth.');
|
|
679
|
+
}
|
|
680
|
+
let route = null;
|
|
681
|
+
if (!isGlobal && (routeId || path)) {
|
|
682
|
+
route = (await resolveRoute(ENFYRA_API_URL, { path, routeId })).route;
|
|
683
|
+
}
|
|
684
|
+
const { methodMap } = await getMethodContext(ENFYRA_API_URL);
|
|
685
|
+
const existing = guardId
|
|
686
|
+
? await findRecord(ENFYRA_API_URL, 'enfyra_guard', { id: { _eq: guardId } }, 'id,_id,name')
|
|
687
|
+
: await findRecord(ENFYRA_API_URL, 'enfyra_guard', { name: { _eq: name } }, 'id,_id,name');
|
|
688
|
+
const guardBody = {
|
|
689
|
+
name,
|
|
690
|
+
position,
|
|
691
|
+
combinator,
|
|
692
|
+
priority,
|
|
693
|
+
isGlobal,
|
|
694
|
+
isEnabled,
|
|
695
|
+
description,
|
|
696
|
+
...(route ? { route: { id: getId(route) } } : {}),
|
|
697
|
+
...(methods?.length ? { methods: resolveMethodRefs(methodMap, methods) } : {}),
|
|
698
|
+
};
|
|
699
|
+
const guardOperation = await createOrPatch(ENFYRA_API_URL, 'enfyra_guard', existing, guardBody);
|
|
700
|
+
const resolvedGuardId = guardOperation.id || getId(existing);
|
|
701
|
+
const existingRules = rulesMode === 'replace'
|
|
702
|
+
? await fetchRecords(ENFYRA_API_URL, 'enfyra_guard_rule', { guard: { id: { _eq: resolvedGuardId } } }, 'id,_id,isEnabled')
|
|
703
|
+
: [];
|
|
704
|
+
const disabledRules = [];
|
|
705
|
+
for (const rule of existingRules) {
|
|
706
|
+
disabledRules.push(await fetchAPI(ENFYRA_API_URL, `/enfyra_guard_rule/${encodeURIComponent(String(getId(rule)))}`, {
|
|
707
|
+
method: 'PATCH',
|
|
708
|
+
body: JSON.stringify({ isEnabled: false }),
|
|
709
|
+
}));
|
|
710
|
+
}
|
|
711
|
+
const createdRules = [];
|
|
712
|
+
if (rulesMode !== 'none') {
|
|
713
|
+
for (const rule of ruleInputs) {
|
|
714
|
+
createdRules.push(await fetchAPI(ENFYRA_API_URL, '/enfyra_guard_rule', {
|
|
715
|
+
method: 'POST',
|
|
716
|
+
body: JSON.stringify({
|
|
717
|
+
type: rule.type,
|
|
718
|
+
config: rule.config,
|
|
719
|
+
priority: rule.priority ?? 0,
|
|
720
|
+
isEnabled: rule.isEnabled ?? true,
|
|
721
|
+
description: rule.description,
|
|
722
|
+
guard: { id: resolvedGuardId },
|
|
723
|
+
...(Array.isArray(rule.userIds) && rule.userIds.length ? { users: rule.userIds.map((id) => ({ id })) } : {}),
|
|
724
|
+
}),
|
|
725
|
+
}));
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/guards');
|
|
729
|
+
return jsonText({
|
|
730
|
+
action: 'guard_ensured',
|
|
731
|
+
guard: { id: resolvedGuardId, name, route: route ? route.path : null, isGlobal },
|
|
732
|
+
guardOperation,
|
|
733
|
+
disabledRuleCount: disabledRules.length,
|
|
734
|
+
createdRuleCount: createdRules.length,
|
|
735
|
+
reload,
|
|
736
|
+
});
|
|
737
|
+
},
|
|
738
|
+
);
|
|
739
|
+
|
|
740
|
+
server.tool(
|
|
741
|
+
'ensure_websocket_gateway',
|
|
742
|
+
'Business operation: create or update an Enfyra Socket.IO gateway. Connection handler code is validated before save.',
|
|
743
|
+
{
|
|
744
|
+
path: z.string().describe('Gateway namespace/path, e.g. /chat.'),
|
|
745
|
+
connectionHandlerScript: z.string().optional().describe('Optional connection handler dynamic script.'),
|
|
746
|
+
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for connection handler.'),
|
|
747
|
+
isEnabled: z.boolean().optional().default(true).describe('Enable gateway.'),
|
|
748
|
+
description: z.string().optional().describe('Admin note.'),
|
|
749
|
+
},
|
|
750
|
+
async ({ path, connectionHandlerScript, scriptLanguage, isEnabled, description }) => {
|
|
751
|
+
const normalizedPath = normalizeRestPath(path);
|
|
752
|
+
const validation = connectionHandlerScript === undefined
|
|
753
|
+
? { validated: false, reason: 'no connectionHandlerScript' }
|
|
754
|
+
: await validateDynamicScript(ENFYRA_API_URL, connectionHandlerScript, scriptLanguage);
|
|
755
|
+
const existing = await findRecord(ENFYRA_API_URL, 'enfyra_websocket', { path: { _eq: normalizedPath } }, 'id,_id,path');
|
|
756
|
+
const body = {
|
|
757
|
+
path: normalizedPath,
|
|
758
|
+
isEnabled,
|
|
759
|
+
description,
|
|
760
|
+
...(connectionHandlerScript !== undefined ? { connectionHandlerScript, scriptLanguage } : {}),
|
|
761
|
+
};
|
|
762
|
+
const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_websocket', existing, body);
|
|
763
|
+
const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/websockets');
|
|
764
|
+
return jsonText({ action: 'websocket_gateway_ensured', gateway: { id: operation.id, path: normalizedPath }, validation, operation, reload });
|
|
765
|
+
},
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
server.tool(
|
|
769
|
+
'ensure_websocket_event',
|
|
770
|
+
'Business operation: create or update one websocket event handler. It resolves gateway path/id and validates handlerScript before save.',
|
|
771
|
+
{
|
|
772
|
+
gatewayPath: z.string().optional().describe('Gateway path, e.g. /chat. Use gatewayPath or gatewayId.'),
|
|
773
|
+
gatewayId: z.union([z.string(), z.number()]).optional().describe('Gateway id. Use gatewayPath or gatewayId.'),
|
|
774
|
+
eventName: z.string().describe('Socket event name.'),
|
|
775
|
+
handlerScript: z.string().describe('Event handler dynamic script.'),
|
|
776
|
+
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
|
|
777
|
+
isEnabled: z.boolean().optional().default(true).describe('Enable event.'),
|
|
778
|
+
description: z.string().optional().describe('Admin note.'),
|
|
779
|
+
},
|
|
780
|
+
async ({ gatewayPath, gatewayId, eventName, handlerScript, scriptLanguage, isEnabled, description }) => {
|
|
781
|
+
if (!gatewayPath && !gatewayId) throw new Error('Provide gatewayPath or gatewayId.');
|
|
782
|
+
if (gatewayPath && gatewayId) throw new Error('Provide gatewayPath or gatewayId, not both.');
|
|
783
|
+
const gateway = gatewayId
|
|
784
|
+
? await findRecord(ENFYRA_API_URL, 'enfyra_websocket', { id: { _eq: gatewayId } }, 'id,_id,path')
|
|
785
|
+
: await findRecord(ENFYRA_API_URL, 'enfyra_websocket', { path: { _eq: normalizeRestPath(gatewayPath) } }, 'id,_id,path');
|
|
786
|
+
if (!gateway) throw new Error(`Websocket gateway not found: ${gatewayId || gatewayPath}`);
|
|
787
|
+
const validation = await validateDynamicScript(ENFYRA_API_URL, handlerScript, scriptLanguage);
|
|
788
|
+
const existing = await findRecord(ENFYRA_API_URL, 'enfyra_websocket_event', {
|
|
789
|
+
gateway: { id: { _eq: getId(gateway) } },
|
|
790
|
+
eventName: { _eq: eventName },
|
|
791
|
+
}, 'id,_id,eventName,gateway.id');
|
|
792
|
+
const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_websocket_event', existing, {
|
|
793
|
+
gateway: { id: getId(gateway) },
|
|
794
|
+
eventName,
|
|
795
|
+
handlerScript,
|
|
796
|
+
scriptLanguage,
|
|
797
|
+
isEnabled,
|
|
798
|
+
description,
|
|
799
|
+
});
|
|
800
|
+
const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/websockets');
|
|
801
|
+
return jsonText({ action: 'websocket_event_ensured', gateway: { id: getId(gateway), path: gateway.path }, eventName, validation, operation, reload });
|
|
802
|
+
},
|
|
803
|
+
);
|
|
804
|
+
|
|
805
|
+
server.tool(
|
|
806
|
+
'ensure_flow',
|
|
807
|
+
'Business operation: create or update an Enfyra flow. maxExecutions defaults to 100 when omitted.',
|
|
808
|
+
{
|
|
809
|
+
name: z.string().describe('Flow name. Existing flow with this name is updated.'),
|
|
810
|
+
trigger: z.string().optional().describe('Flow trigger type/key.'),
|
|
811
|
+
timeout: z.number().int().positive().optional().describe('Flow timeout in ms.'),
|
|
812
|
+
maxExecutions: z.number().int().positive().optional().default(100).describe('Execution history cap.'),
|
|
813
|
+
isEnabled: z.boolean().optional().default(true).describe('Enable flow.'),
|
|
814
|
+
description: z.string().optional().describe('Admin note.'),
|
|
815
|
+
},
|
|
816
|
+
async ({ name, trigger, timeout, maxExecutions, isEnabled, description }) => {
|
|
817
|
+
const existing = await findRecord(ENFYRA_API_URL, 'enfyra_flow', { name: { _eq: name } }, 'id,_id,name');
|
|
818
|
+
const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_flow', existing, {
|
|
819
|
+
name,
|
|
820
|
+
trigger,
|
|
821
|
+
timeout,
|
|
822
|
+
maxExecutions,
|
|
823
|
+
isEnabled,
|
|
824
|
+
description,
|
|
825
|
+
});
|
|
826
|
+
const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/flows');
|
|
827
|
+
return jsonText({ action: 'flow_ensured', flow: { id: operation.id, name }, operation, reload });
|
|
828
|
+
},
|
|
829
|
+
);
|
|
830
|
+
|
|
831
|
+
server.tool(
|
|
832
|
+
'ensure_flow_step',
|
|
833
|
+
'Business operation: create or update one flow step by flow+key. Script/condition sourceCode is validated before save.',
|
|
834
|
+
{
|
|
835
|
+
flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
|
|
836
|
+
flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
|
|
837
|
+
key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
|
|
838
|
+
type: z.string().describe('Step type, e.g. script, condition, query, http, sleep, trigger_flow.'),
|
|
839
|
+
order: z.number().optional().default(0).describe('Step order.'),
|
|
840
|
+
config: z.string().optional().describe('Step config JSON object.'),
|
|
841
|
+
sourceCode: z.string().optional().describe('Script/condition sourceCode.'),
|
|
842
|
+
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
|
|
843
|
+
timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
|
|
844
|
+
isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
|
|
845
|
+
description: z.string().optional().describe('Admin note.'),
|
|
846
|
+
},
|
|
847
|
+
async ({ flowName, flowId, key, type, order, config, sourceCode, scriptLanguage, timeout, isEnabled, description }) => {
|
|
848
|
+
if (!flowName && !flowId) throw new Error('Provide flowName or flowId.');
|
|
849
|
+
if (flowName && flowId) throw new Error('Provide flowName or flowId, not both.');
|
|
850
|
+
const flow = flowId
|
|
851
|
+
? await findRecord(ENFYRA_API_URL, 'enfyra_flow', { id: { _eq: flowId } }, 'id,_id,name')
|
|
852
|
+
: await findRecord(ENFYRA_API_URL, 'enfyra_flow', { name: { _eq: flowName } }, 'id,_id,name');
|
|
853
|
+
if (!flow) throw new Error(`Flow not found: ${flowId || flowName}`);
|
|
854
|
+
const parsedConfig = parseJsonObjectArg('config', config, {});
|
|
855
|
+
const validation = sourceCode && ['script', 'condition'].includes(type)
|
|
856
|
+
? await validateDynamicScript(ENFYRA_API_URL, sourceCode, scriptLanguage)
|
|
857
|
+
: { validated: false, reason: 'no script validation required' };
|
|
858
|
+
const existing = await findRecord(ENFYRA_API_URL, 'enfyra_flow_step', {
|
|
859
|
+
flow: { id: { _eq: getId(flow) } },
|
|
860
|
+
key: { _eq: key },
|
|
861
|
+
}, 'id,_id,key,flow.id');
|
|
862
|
+
const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_flow_step', existing, normalizeFlowStepBody({
|
|
863
|
+
key,
|
|
864
|
+
type,
|
|
865
|
+
order,
|
|
866
|
+
config: parsedConfig,
|
|
867
|
+
sourceCode,
|
|
868
|
+
scriptLanguage,
|
|
869
|
+
timeout,
|
|
870
|
+
isEnabled,
|
|
871
|
+
description,
|
|
872
|
+
}, getId(flow)));
|
|
873
|
+
const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/flows');
|
|
874
|
+
return jsonText({ action: 'flow_step_ensured', flow: { id: getId(flow), name: flow.name }, step: { id: operation.id, key, type }, validation, operation, reload });
|
|
875
|
+
},
|
|
876
|
+
);
|
|
877
|
+
|
|
878
|
+
server.tool(
|
|
879
|
+
'ensure_menu_extension_page',
|
|
880
|
+
'Business operation: create or update a page extension and its menu item together. Extension code is validated before save.',
|
|
881
|
+
{
|
|
882
|
+
name: z.string().describe('Extension unique name.'),
|
|
883
|
+
code: z.string().describe('Vue SFC extension code.'),
|
|
884
|
+
menuLabel: z.string().describe('Menu label.'),
|
|
885
|
+
menuPath: z.string().describe('Admin app path, e.g. /reports.'),
|
|
886
|
+
icon: z.string().optional().describe('Menu icon name.'),
|
|
887
|
+
permission: z.string().optional().describe('Menu permission JSON object.'),
|
|
888
|
+
description: z.string().optional().describe('Description for menu/extension.'),
|
|
889
|
+
isEnabled: z.boolean().optional().default(true).describe('Enable menu and extension.'),
|
|
890
|
+
version: z.string().optional().default('1.0.0').describe('Extension version.'),
|
|
891
|
+
},
|
|
892
|
+
async ({ name, code, menuLabel, menuPath, icon, permission, description, isEnabled, version }) => {
|
|
893
|
+
const validation = await validateExtensionCode(ENFYRA_API_URL, code, name);
|
|
894
|
+
const normalizedPath = normalizeRestPath(menuPath);
|
|
895
|
+
const existingMenu = await findRecord(ENFYRA_API_URL, 'enfyra_menu', { path: { _eq: normalizedPath } }, 'id,_id,path,label');
|
|
896
|
+
const menuOperation = await createOrPatch(ENFYRA_API_URL, 'enfyra_menu', existingMenu, {
|
|
897
|
+
label: menuLabel,
|
|
898
|
+
path: normalizedPath,
|
|
899
|
+
icon,
|
|
900
|
+
type: 'Menu',
|
|
901
|
+
permission: parseJsonObjectArg('permission', permission, undefined),
|
|
902
|
+
description,
|
|
903
|
+
isEnabled,
|
|
904
|
+
});
|
|
905
|
+
const existingExtension = await findRecord(ENFYRA_API_URL, 'enfyra_extension', { name: { _eq: name } }, 'id,_id,name,menu.id');
|
|
906
|
+
const extensionOperation = await createOrPatch(ENFYRA_API_URL, 'enfyra_extension', existingExtension, {
|
|
907
|
+
name,
|
|
908
|
+
type: 'page',
|
|
909
|
+
code,
|
|
910
|
+
menu: { id: menuOperation.id || getId(existingMenu) },
|
|
911
|
+
description,
|
|
912
|
+
isEnabled,
|
|
913
|
+
version,
|
|
914
|
+
});
|
|
915
|
+
return jsonText({
|
|
916
|
+
action: 'menu_extension_page_ensured',
|
|
917
|
+
menu: { id: menuOperation.id || getId(existingMenu), path: normalizedPath, action: menuOperation.action },
|
|
918
|
+
extension: { id: extensionOperation.id || getId(existingExtension), name, action: extensionOperation.action },
|
|
919
|
+
validation,
|
|
920
|
+
});
|
|
921
|
+
},
|
|
922
|
+
);
|
|
923
|
+
}
|
package/src/mcp-server-entry.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { fetchAPI, validateFilter, validateTableName } from './lib/fetch.js';
|
|
|
21
21
|
import { buildMcpServerInstructions, buildGraphqlUrls } from './lib/mcp-instructions.js';
|
|
22
22
|
import { getExamples, listExampleCategories } from './lib/mcp-examples.js';
|
|
23
23
|
import { registerTableTools } from './lib/table-tools.js';
|
|
24
|
+
import { registerPlatformOperationTools } from './lib/platform-operation-tools.js';
|
|
24
25
|
import { prepareRecordMutation, validateScriptSourceIfPresent } from './lib/mutation-guards.js';
|
|
25
26
|
import { validateMainTableRoutePath } from './lib/route-guards.js';
|
|
26
27
|
import { installColumnarToolFormatter, jsonContent } from './lib/response-format.js';
|
|
@@ -2841,6 +2842,7 @@ server.tool(
|
|
|
2841
2842
|
|
|
2842
2843
|
// Register table tools
|
|
2843
2844
|
registerTableTools(server, ENFYRA_API_URL);
|
|
2845
|
+
registerPlatformOperationTools(server, ENFYRA_API_URL);
|
|
2844
2846
|
|
|
2845
2847
|
// ============================================================================
|
|
2846
2848
|
// CACHE & SYSTEM TOOLS
|