@enfyra/mcp-server 0.0.19 → 0.0.20
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/package.json +1 -1
- package/src/lib/mcp-instructions.js +52 -7
- package/src/lib/table-tools.js +51 -14
- package/src/mcp-server-entry.mjs +1157 -7
package/src/mcp-server-entry.mjs
CHANGED
|
@@ -23,6 +23,258 @@ import { registerTableTools } from './lib/table-tools.js';
|
|
|
23
23
|
// Initialize auth module
|
|
24
24
|
initAuth(ENFYRA_API_URL, ENFYRA_EMAIL, ENFYRA_PASSWORD);
|
|
25
25
|
|
|
26
|
+
const CAPABILITY_AREAS = [
|
|
27
|
+
{
|
|
28
|
+
area: 'Schema and metadata',
|
|
29
|
+
tables: ['table_definition', 'column_definition', 'relation_definition', 'schema_migration_definition'],
|
|
30
|
+
workflow: 'Use table tools for table/column/relation schema changes. column_definition and session_definition are internal/no-route; do not CRUD them directly.',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
area: 'Dynamic REST API',
|
|
34
|
+
tables: ['route_definition', 'route_handler_definition', 'pre_hook_definition', 'post_hook_definition', 'route_permission_definition', 'method_definition'],
|
|
35
|
+
workflow: 'Create paths with create_route on an existing main table, then add handlers/hooks. REST methods are GET/POST/PATCH/DELETE.',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
area: 'Auth, roles, sessions, OAuth',
|
|
39
|
+
tables: ['user_definition', 'role_definition', 'session_definition', 'oauth_config_definition', 'oauth_account_definition'],
|
|
40
|
+
workflow: 'Email/password login is /auth/login. OAuth is browser redirect based. session_definition is internal/no-route.',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
area: 'Guards and permissions',
|
|
44
|
+
tables: ['guard_definition', 'guard_rule_definition', 'field_permission_definition', 'column_rule_definition'],
|
|
45
|
+
workflow: 'Use route guard metadata for request gating, field permissions for record field access, and column rules for body validation.',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
area: 'GraphQL',
|
|
49
|
+
tables: ['gql_definition'],
|
|
50
|
+
workflow: 'Enable per table through gql_definition or update_table graphqlEnabled. GraphQL requires Bearer auth.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
area: 'Files and storage',
|
|
54
|
+
tables: ['file_definition', 'file_permission_definition', 'folder_definition', 'storage_config_definition'],
|
|
55
|
+
workflow: 'Use file endpoints/helpers for uploads and asset streaming; metadata tables describe files, permissions, folders, and storage backends.',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
area: 'WebSocket',
|
|
59
|
+
tables: ['websocket_definition', 'websocket_event_definition'],
|
|
60
|
+
workflow: 'Socket.IO gateways/events are metadata-backed. Use admin test runner for handler scripts before relying on a real client.',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
area: 'Flows',
|
|
64
|
+
tables: ['flow_definition', 'flow_step_definition', 'flow_execution_definition'],
|
|
65
|
+
workflow: 'Create flows and steps via CRUD, test steps with test_flow_step/run_admin_test, trigger with trigger_flow.',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
area: 'Extensions, menus, packages',
|
|
69
|
+
tables: ['extension_definition', 'menu_definition', 'package_definition', 'bootstrap_script_definition'],
|
|
70
|
+
workflow: 'Extensions are Vue SFC records. Use install_package for package_definition rather than raw CRUD.',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
area: 'Settings and platform config',
|
|
74
|
+
tables: ['setting_definition', 'cors_origin_definition'],
|
|
75
|
+
workflow: 'Settings and CORS origins are metadata-backed platform configuration.',
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
const FILTER_OPERATORS = [
|
|
80
|
+
'_eq',
|
|
81
|
+
'_neq',
|
|
82
|
+
'_gt',
|
|
83
|
+
'_gte',
|
|
84
|
+
'_lt',
|
|
85
|
+
'_lte',
|
|
86
|
+
'_in',
|
|
87
|
+
'_not_in',
|
|
88
|
+
'_nin',
|
|
89
|
+
'_contains',
|
|
90
|
+
'_starts_with',
|
|
91
|
+
'_ends_with',
|
|
92
|
+
'_between',
|
|
93
|
+
'_is_null',
|
|
94
|
+
'_is_not_null',
|
|
95
|
+
'_and',
|
|
96
|
+
'_or',
|
|
97
|
+
'_not',
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const FIELD_PERMISSION_CONDITION_OPERATORS = [
|
|
101
|
+
'_eq',
|
|
102
|
+
'_neq',
|
|
103
|
+
'_gt',
|
|
104
|
+
'_gte',
|
|
105
|
+
'_lt',
|
|
106
|
+
'_lte',
|
|
107
|
+
'_in',
|
|
108
|
+
'_not_in',
|
|
109
|
+
'_nin',
|
|
110
|
+
'_is_null',
|
|
111
|
+
'_is_not_null',
|
|
112
|
+
'_and',
|
|
113
|
+
'_or',
|
|
114
|
+
'_not',
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
function normalizeTables(metadata) {
|
|
118
|
+
const tablesSource = metadata?.data?.tables || metadata?.tables || metadata?.data || [];
|
|
119
|
+
return Array.isArray(tablesSource)
|
|
120
|
+
? tablesSource
|
|
121
|
+
: Object.values(tablesSource || {});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getPrimaryColumn(table) {
|
|
125
|
+
return (table?.columns || []).find((column) => column.isPrimary) || null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function inferPrimaryKeyContext(tables) {
|
|
129
|
+
const primaryColumns = tables
|
|
130
|
+
.map((table) => ({ table: table.name, primaryKey: getPrimaryColumn(table)?.name || null }))
|
|
131
|
+
.filter((item) => item.primaryKey);
|
|
132
|
+
const counts = primaryColumns.reduce((acc, item) => {
|
|
133
|
+
acc[item.primaryKey] = (acc[item.primaryKey] || 0) + 1;
|
|
134
|
+
return acc;
|
|
135
|
+
}, {});
|
|
136
|
+
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
|
|
137
|
+
return {
|
|
138
|
+
dominantPrimaryKey: dominant,
|
|
139
|
+
counts,
|
|
140
|
+
inferredBackendFamily: dominant === '_id' ? 'mongodb-like' : dominant === 'id' ? 'sql-like' : 'unknown',
|
|
141
|
+
exactDatabaseType: 'not exposed by current public/admin API; infer from metadata or add a backend context endpoint for exact mysql/postgres/mongodb/sqlite',
|
|
142
|
+
sampleTables: primaryColumns.slice(0, 12),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function getMetadataDatabaseContext(metadata, tables) {
|
|
147
|
+
const inferred = inferPrimaryKeyContext(tables);
|
|
148
|
+
return {
|
|
149
|
+
dbType: metadata?.dbType || metadata?.data?.dbType || null,
|
|
150
|
+
pkField: metadata?.pkField || metadata?.data?.pkField || inferred.dominantPrimaryKey,
|
|
151
|
+
inferredBackendFamily: inferred.inferredBackendFamily,
|
|
152
|
+
primaryKeyCounts: inferred.counts,
|
|
153
|
+
source: metadata?.dbType || metadata?.data?.dbType
|
|
154
|
+
? 'metadata'
|
|
155
|
+
: 'inferred from table primary columns',
|
|
156
|
+
sampleTables: inferred.sampleTables,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function summarizeTable(table) {
|
|
161
|
+
if (!table) return null;
|
|
162
|
+
return {
|
|
163
|
+
id: table.id ?? table._id,
|
|
164
|
+
name: table.name,
|
|
165
|
+
alias: table.alias,
|
|
166
|
+
primaryKey: getPrimaryColumn(table)?.name || null,
|
|
167
|
+
validateBody: table.validateBody,
|
|
168
|
+
graphqlEnabled: table.graphqlEnabled,
|
|
169
|
+
columns: (table.columns || []).map((column) => ({
|
|
170
|
+
id: column.id ?? column._id,
|
|
171
|
+
name: column.name,
|
|
172
|
+
type: column.type,
|
|
173
|
+
isPrimary: !!column.isPrimary,
|
|
174
|
+
isNullable: column.isNullable,
|
|
175
|
+
isPublished: column.isPublished,
|
|
176
|
+
})),
|
|
177
|
+
relations: (table.relations || []).map((relation) => ({
|
|
178
|
+
id: relation.id ?? relation._id,
|
|
179
|
+
propertyName: relation.propertyName,
|
|
180
|
+
type: relation.type,
|
|
181
|
+
targetTable: relation.targetTable?.name || relation.targetTableName || relation.targetTable,
|
|
182
|
+
inversePropertyName: relation.inversePropertyName,
|
|
183
|
+
mappedBy: relation.mappedBy?.propertyName || relation.mappedBy,
|
|
184
|
+
isNullable: relation.isNullable,
|
|
185
|
+
onDelete: relation.onDelete,
|
|
186
|
+
isPublished: relation.isPublished,
|
|
187
|
+
})),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function summarizeRoutes(routesResult) {
|
|
192
|
+
return (routesResult?.data || []).map((route) => ({
|
|
193
|
+
id: route.id ?? route._id,
|
|
194
|
+
path: route.path,
|
|
195
|
+
mainTable: route.mainTable?.name || route.mainTableName || null,
|
|
196
|
+
availableMethods: (route.availableMethods || []).map((method) => method.method).filter(Boolean),
|
|
197
|
+
publishedMethods: (route.publishedMethods || []).map((method) => method.method).filter(Boolean),
|
|
198
|
+
isEnabled: route.isEnabled,
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function unwrapData(result) {
|
|
203
|
+
return Array.isArray(result?.data) ? result.data : [];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function getId(record) {
|
|
207
|
+
return record?.id ?? record?._id ?? null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sameId(a, b) {
|
|
211
|
+
if (a === null || a === undefined || b === null || b === undefined) return false;
|
|
212
|
+
return String(a) === String(b);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function refId(value) {
|
|
216
|
+
return typeof value === 'object' && value !== null ? getId(value) : value;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function firstDataRecord(result) {
|
|
220
|
+
return Array.isArray(result?.data) ? result.data[0] : result;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function resultRecordId(result) {
|
|
224
|
+
return getId(firstDataRecord(result));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function parseJsonArg(value, fallback = undefined) {
|
|
228
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
229
|
+
return JSON.parse(value);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function normalizeRestPath(path) {
|
|
233
|
+
if (!path) return '/';
|
|
234
|
+
if (/^https?:\/\//i.test(path)) {
|
|
235
|
+
throw new Error('Only Enfyra API paths are allowed, not full external URLs');
|
|
236
|
+
}
|
|
237
|
+
return path.startsWith('/') ? path : `/${path}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function pickCodeSummary(record, fieldName) {
|
|
241
|
+
const code = record?.[fieldName];
|
|
242
|
+
return {
|
|
243
|
+
...record,
|
|
244
|
+
[fieldName]: typeof code === 'string'
|
|
245
|
+
? {
|
|
246
|
+
length: code.length,
|
|
247
|
+
preview: code.length > 700 ? `${code.slice(0, 700)}...` : code,
|
|
248
|
+
}
|
|
249
|
+
: code,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function fetchAll(path) {
|
|
254
|
+
return unwrapData(await fetchAPI(ENFYRA_API_URL, path));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function getMetadataTables() {
|
|
258
|
+
const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
|
|
259
|
+
return {
|
|
260
|
+
metadata,
|
|
261
|
+
tables: normalizeTables(metadata),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function resolveTableOrThrow(tables, tableName) {
|
|
266
|
+
const table = tables.find((item) => item?.name === tableName || item?.alias === tableName);
|
|
267
|
+
if (!table) throw new Error(`Unknown table "${tableName}"`);
|
|
268
|
+
return table;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function resolveFieldOrThrow(table, fieldName, kind = 'column') {
|
|
272
|
+
const list = kind === 'relation' ? table.relations || [] : table.columns || [];
|
|
273
|
+
const field = list.find((item) => item.name === fieldName || item.propertyName === fieldName);
|
|
274
|
+
if (!field) throw new Error(`Unknown ${kind} "${fieldName}" on table "${table.name}"`);
|
|
275
|
+
return field;
|
|
276
|
+
}
|
|
277
|
+
|
|
26
278
|
// Create MCP server — `instructions` is sent to the host (e.g. Claude Code) for the LLM; not README
|
|
27
279
|
const server = new McpServer(
|
|
28
280
|
{
|
|
@@ -50,6 +302,337 @@ server.tool('get_table_metadata', 'Get metadata for a specific table by name', {
|
|
|
50
302
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
51
303
|
});
|
|
52
304
|
|
|
305
|
+
server.tool(
|
|
306
|
+
'discover_enfyra_system',
|
|
307
|
+
[
|
|
308
|
+
'Call this first when you need to understand the live Enfyra instance.',
|
|
309
|
+
'Returns a concise capability map from live metadata/routes/method rows, including schema management, REST route behavior, GraphQL enablement, and relation handling.',
|
|
310
|
+
].join(' '),
|
|
311
|
+
{},
|
|
312
|
+
async () => {
|
|
313
|
+
const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
|
|
314
|
+
const [routesResult, methodsResult] = await Promise.all([
|
|
315
|
+
fetchAPI(ENFYRA_API_URL, '/route_definition?fields=path,mainTable.name,availableMethods.*,publishedMethods.*&limit=1000'),
|
|
316
|
+
fetchAPI(ENFYRA_API_URL, '/method_definition?limit=100'),
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
const tables = normalizeTables(metadata);
|
|
320
|
+
const tableNames = tables.map((table) => table?.name).filter(Boolean).sort();
|
|
321
|
+
const routes = summarizeRoutes(routesResult);
|
|
322
|
+
const routeTables = new Set(routes.map((route) => route.mainTable).filter(Boolean));
|
|
323
|
+
const noRouteTables = tableNames.filter((name) => !routeTables.has(name));
|
|
324
|
+
const relationTable = tables.find((table) => table?.name === 'relation_definition');
|
|
325
|
+
const tableDefinition = tables.find((table) => table?.name === 'table_definition');
|
|
326
|
+
const gqlDefinition = tables.find((table) => table?.name === 'gql_definition');
|
|
327
|
+
const routeTableList = [...routeTables].sort();
|
|
328
|
+
|
|
329
|
+
const payload = {
|
|
330
|
+
apiBase: ENFYRA_API_URL.replace(/\/$/, ''),
|
|
331
|
+
counts: {
|
|
332
|
+
tables: tableNames.length,
|
|
333
|
+
routes: routes.length,
|
|
334
|
+
methods: methodsResult?.data?.length || 0,
|
|
335
|
+
},
|
|
336
|
+
methods: (methodsResult?.data || []).map((method) => ({ id: method.id || method._id, method: method.method })),
|
|
337
|
+
capabilityAreas: CAPABILITY_AREAS.map((item) => ({
|
|
338
|
+
...item,
|
|
339
|
+
presentTables: item.tables.filter((table) => tableNames.includes(table)),
|
|
340
|
+
routeBackedTables: item.tables.filter((table) => routeTables.has(table)),
|
|
341
|
+
noRouteTables: item.tables.filter((table) => tableNames.includes(table) && !routeTables.has(table)),
|
|
342
|
+
})),
|
|
343
|
+
rest: {
|
|
344
|
+
routePattern: 'Dynamic REST routes expose GET/POST at /<route-path> and PATCH/DELETE at /<route-path>/:id; there is no GET /<route-path>/:id.',
|
|
345
|
+
publicAccess: 'publishedMethods controls anonymous REST access per route/method; otherwise Bearer JWT + routePermissions apply.',
|
|
346
|
+
routeTables: routeTableList,
|
|
347
|
+
noRouteTables,
|
|
348
|
+
canonicalCrudTools: 'query_table/create_record/update_record/delete_record use dynamic REST routes and only work for route-backed tables.',
|
|
349
|
+
customRouteWorkflow: 'For a new endpoint use create_route against an existing table, then create_handler/create_pre_hook/create_post_hook. Do not create a table just to get a path.',
|
|
350
|
+
},
|
|
351
|
+
schemaManagement: {
|
|
352
|
+
createTable: 'POST /table_definition supports columns and relations arrays in the same cascade call. MCP create_table exposes both.',
|
|
353
|
+
updateTable: 'PATCH /table_definition/:id is the canonical path for table property changes and column/relation schema changes.',
|
|
354
|
+
columns: 'column_definition has no REST route; use create_table/create_column/update_column/delete_column.',
|
|
355
|
+
relations: routeTables.has('relation_definition')
|
|
356
|
+
? 'relation_definition has a REST route for reads/metadata, but canonical schema migration is create_relation/delete_relation or table_definition PATCH with the full relations array. Relation onDelete accepts CASCADE, SET NULL, or RESTRICT.'
|
|
357
|
+
: 'Use create_relation/delete_relation or table_definition PATCH with the full relations array. Relation onDelete accepts CASCADE, SET NULL, or RESTRICT.',
|
|
358
|
+
tableDefinitionRelations: (tableDefinition?.relations || []).map((rel) => rel.propertyName),
|
|
359
|
+
relationDefinitionRelations: (relationTable?.relations || []).map((rel) => rel.propertyName),
|
|
360
|
+
},
|
|
361
|
+
adminTesting: {
|
|
362
|
+
runAdminTest: 'run_admin_test wraps POST /admin/test/run for flow_step, websocket_event, and websocket_connection scripts.',
|
|
363
|
+
testFlowStep: 'test_flow_step wraps POST /admin/flow/test-step.',
|
|
364
|
+
triggerFlow: 'trigger_flow wraps POST /admin/flow/trigger/:id and enqueues a flow execution.',
|
|
365
|
+
},
|
|
366
|
+
graphql: {
|
|
367
|
+
endpoint: `${ENFYRA_API_URL.replace(/\/$/, '')}/graphql`,
|
|
368
|
+
schemaEndpoint: `${ENFYRA_API_URL.replace(/\/$/, '')}/graphql-schema`,
|
|
369
|
+
enablement: 'A table appears in GraphQL when gql_definition has an enabled row for that table. REST route availableMethods does not enable GraphQL.',
|
|
370
|
+
auth: 'GraphQL currently requires Authorization: Bearer <accessToken>; REST publishedMethods does not make GraphQL anonymous.',
|
|
371
|
+
management: routeTables.has('gql_definition')
|
|
372
|
+
? 'Use update_table graphqlEnabled or create/update records on gql_definition, then reload_graphql if needed.'
|
|
373
|
+
: 'Use update_table graphqlEnabled, then reload_graphql if needed.',
|
|
374
|
+
gqlDefinitionColumns: (gqlDefinition?.columns || []).map((column) => column.name),
|
|
375
|
+
},
|
|
376
|
+
tableNames,
|
|
377
|
+
routes,
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
381
|
+
},
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
server.tool(
|
|
385
|
+
'discover_runtime_context',
|
|
386
|
+
[
|
|
387
|
+
'Discover live runtime context that affects how an LLM should use Enfyra.',
|
|
388
|
+
'Reports inferred primary key/backend family, route/cache/admin surfaces, active metadata-backed runtime areas, and what is not exposed by the backend API.',
|
|
389
|
+
].join(' '),
|
|
390
|
+
{},
|
|
391
|
+
async () => {
|
|
392
|
+
const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
|
|
393
|
+
const [
|
|
394
|
+
routesResult,
|
|
395
|
+
methodsResult,
|
|
396
|
+
gqlResult,
|
|
397
|
+
flowsResult,
|
|
398
|
+
websocketResult,
|
|
399
|
+
storageResult,
|
|
400
|
+
settingsResult,
|
|
401
|
+
meResult,
|
|
402
|
+
] = await Promise.all([
|
|
403
|
+
fetchAPI(ENFYRA_API_URL, '/route_definition?fields=path,mainTable.name,availableMethods.*,publishedMethods.*,isEnabled&limit=1000'),
|
|
404
|
+
fetchAPI(ENFYRA_API_URL, '/method_definition?limit=100'),
|
|
405
|
+
fetchAPI(ENFYRA_API_URL, '/gql_definition?limit=1000').catch((error) => ({ error: String(error.message || error), data: [] })),
|
|
406
|
+
fetchAPI(ENFYRA_API_URL, '/flow_definition?limit=1000').catch((error) => ({ error: String(error.message || error), data: [] })),
|
|
407
|
+
fetchAPI(ENFYRA_API_URL, '/websocket_definition?limit=1000').catch((error) => ({ error: String(error.message || error), data: [] })),
|
|
408
|
+
fetchAPI(ENFYRA_API_URL, '/storage_config_definition?limit=1000').catch((error) => ({ error: String(error.message || error), data: [] })),
|
|
409
|
+
fetchAPI(ENFYRA_API_URL, '/setting_definition?limit=1000').catch((error) => ({ error: String(error.message || error), data: [] })),
|
|
410
|
+
fetchAPI(ENFYRA_API_URL, '/me').catch((error) => ({ error: String(error.message || error), data: [] })),
|
|
411
|
+
]);
|
|
412
|
+
|
|
413
|
+
const tables = normalizeTables(metadata);
|
|
414
|
+
const routes = summarizeRoutes(routesResult);
|
|
415
|
+
const routeTables = new Set(routes.map((route) => route.mainTable).filter(Boolean));
|
|
416
|
+
const adminRoutes = routes.filter((route) => route.path?.startsWith('/admin'));
|
|
417
|
+
const publicRoutes = routes.filter((route) => route.publishedMethods?.length);
|
|
418
|
+
|
|
419
|
+
const payload = {
|
|
420
|
+
apiBase: ENFYRA_API_URL.replace(/\/$/, ''),
|
|
421
|
+
authenticatedUser: Array.isArray(meResult?.data) ? meResult.data[0] || null : meResult?.data || null,
|
|
422
|
+
database: getMetadataDatabaseContext(metadata, tables),
|
|
423
|
+
counts: {
|
|
424
|
+
tables: tables.length,
|
|
425
|
+
routes: routes.length,
|
|
426
|
+
routeBackedTables: routeTables.size,
|
|
427
|
+
noRouteTables: tables.filter((table) => !routeTables.has(table.name)).length,
|
|
428
|
+
methods: methodsResult?.data?.length || 0,
|
|
429
|
+
graphqlDefinitions: gqlResult?.data?.length || 0,
|
|
430
|
+
enabledGraphqlDefinitions: (gqlResult?.data || []).filter((row) => row.isEnabled !== false).length,
|
|
431
|
+
flows: flowsResult?.data?.length || 0,
|
|
432
|
+
enabledFlows: (flowsResult?.data || []).filter((row) => row.isEnabled !== false).length,
|
|
433
|
+
websocketGateways: websocketResult?.data?.length || 0,
|
|
434
|
+
enabledWebsocketGateways: (websocketResult?.data || []).filter((row) => row.isEnabled !== false).length,
|
|
435
|
+
storageConfigs: storageResult?.data?.length || 0,
|
|
436
|
+
settings: settingsResult?.data?.length || 0,
|
|
437
|
+
},
|
|
438
|
+
methods: (methodsResult?.data || []).map((method) => ({ id: method.id || method._id, method: method.method })),
|
|
439
|
+
routeRuntime: {
|
|
440
|
+
routePattern: 'GET/POST /<route-path>; PATCH/DELETE /<route-path>/:id; no dynamic GET /<route-path>/:id.',
|
|
441
|
+
adminRoutes: adminRoutes.map((route) => route.path).sort(),
|
|
442
|
+
publicRoutes: publicRoutes.map((route) => ({
|
|
443
|
+
path: route.path,
|
|
444
|
+
mainTable: route.mainTable,
|
|
445
|
+
publishedMethods: route.publishedMethods,
|
|
446
|
+
})),
|
|
447
|
+
},
|
|
448
|
+
cacheAndCluster: {
|
|
449
|
+
metadataMutationReloads: 'Metadata-backed mutations emit cache invalidation; admin reload endpoints exist for metadata/routes/graphql/guards/all.',
|
|
450
|
+
multiInstanceContract: 'Backend is cluster-aware through cache invalidation and Redis/BullMQ paths, but this MCP can only observe metadata/API state, not every node health.',
|
|
451
|
+
flowWorkerContract: 'Flow jobs require the backend flow worker to be initialized after HTTP listen and websocket gateway init; trigger_flow only confirms enqueue/result from admin endpoint.',
|
|
452
|
+
},
|
|
453
|
+
runtimeGaps: [
|
|
454
|
+
metadata?.dbType || metadata?.data?.dbType
|
|
455
|
+
? null
|
|
456
|
+
: 'Exact database type is not exposed by current MCP-visible API.',
|
|
457
|
+
'Redis/BullMQ/socket adapter health is not exposed by current MCP-visible API.',
|
|
458
|
+
'MCP can test flow steps and websocket scripts through admin test endpoints, but not prove every production queue/client path without a real end-to-end client.',
|
|
459
|
+
].filter(Boolean),
|
|
460
|
+
};
|
|
461
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
server.tool(
|
|
466
|
+
'discover_query_capabilities',
|
|
467
|
+
[
|
|
468
|
+
'Discover Enfyra query/filter/deep-fetch capabilities for the live instance.',
|
|
469
|
+
'Optionally pass tableName to include columns, relations, primary key, route paths, and examples for that table.',
|
|
470
|
+
].join(' '),
|
|
471
|
+
{
|
|
472
|
+
tableName: z.string().optional().describe('Optional table name to summarize query fields and relation/deep capabilities.'),
|
|
473
|
+
},
|
|
474
|
+
async ({ tableName }) => {
|
|
475
|
+
const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
|
|
476
|
+
const routesResult = await fetchAPI(ENFYRA_API_URL, '/route_definition?fields=path,mainTable.name,availableMethods.*,publishedMethods.*,isEnabled&limit=1000');
|
|
477
|
+
const tables = normalizeTables(metadata);
|
|
478
|
+
const routes = summarizeRoutes(routesResult);
|
|
479
|
+
const table = tableName ? tables.find((item) => item.name === tableName) : null;
|
|
480
|
+
const primaryKey = table ? getPrimaryColumn(table)?.name || 'id' : inferPrimaryKeyContext(tables).dominantPrimaryKey || 'id';
|
|
481
|
+
const tableRoutes = tableName
|
|
482
|
+
? routes.filter((route) => route.mainTable === tableName)
|
|
483
|
+
: [];
|
|
484
|
+
|
|
485
|
+
const payload = {
|
|
486
|
+
operators: {
|
|
487
|
+
filter: FILTER_OPERATORS,
|
|
488
|
+
fieldPermissionConditions: FIELD_PERMISSION_CONDITION_OPERATORS,
|
|
489
|
+
fieldPermissionConditionUnsupported: ['_contains', '_starts_with', '_ends_with', '_between'],
|
|
490
|
+
},
|
|
491
|
+
queryParams: {
|
|
492
|
+
fields: 'Comma-separated scalar/relation fields. Relations use relation propertyName, not physical FK column names.',
|
|
493
|
+
filter: 'JSON object using operators above. Relation filters use nested relation propertyName objects.',
|
|
494
|
+
sort: 'Field name or -field. Dotted relation sort is constrained by relation type and deep validation.',
|
|
495
|
+
page: '1-based page.',
|
|
496
|
+
limit: 'Page size.',
|
|
497
|
+
meta: 'Request metadata/counts where supported.',
|
|
498
|
+
deep: 'Nested relation fetch object keyed by relation propertyName.',
|
|
499
|
+
},
|
|
500
|
+
deep: {
|
|
501
|
+
shape: '{ [relationName]: { fields?, filter?, sort?, limit?, page?, deep? } }',
|
|
502
|
+
rules: [
|
|
503
|
+
'Unknown relation keys are invalid.',
|
|
504
|
+
'Unknown deep entry keys are invalid.',
|
|
505
|
+
'limit on many-to-one/one-to-one relations is invalid.',
|
|
506
|
+
'Dotted sort through one-to-many/many-to-many is invalid.',
|
|
507
|
+
'Nested deep is recursively validated.',
|
|
508
|
+
'Field permissions may rewrite filters/sorts and sanitize post-query results.',
|
|
509
|
+
],
|
|
510
|
+
},
|
|
511
|
+
backendNotes: {
|
|
512
|
+
primaryKey: 'SQL commonly uses id; Mongo uses _id. Use table metadata primary column when available.',
|
|
513
|
+
relationNames: 'API relation operations use relation propertyName, not physical FK column names.',
|
|
514
|
+
graphql: 'GraphQL query args also accept filter/sort/page/limit, but GraphQL requires Bearer auth and table enablement via gql_definition.',
|
|
515
|
+
},
|
|
516
|
+
table: tableName
|
|
517
|
+
? {
|
|
518
|
+
exists: !!table,
|
|
519
|
+
metadata: summarizeTable(table),
|
|
520
|
+
routes: tableRoutes,
|
|
521
|
+
examples: table
|
|
522
|
+
? {
|
|
523
|
+
list: `GET /${tableRoutes[0]?.path?.replace(/^\//, '') || table.name}?limit=10`,
|
|
524
|
+
oneByPkFilter: { [primaryKey]: { _eq: '<id>' } },
|
|
525
|
+
relationDeep: (table.relations || [])[0]
|
|
526
|
+
? { [(table.relations || [])[0].propertyName]: { fields: ['id'], limit: 5 } }
|
|
527
|
+
: null,
|
|
528
|
+
relationFilter: (table.relations || [])[0]
|
|
529
|
+
? { [(table.relations || [])[0].propertyName]: { [primaryKey]: { _eq: '<related-id>' } } }
|
|
530
|
+
: null,
|
|
531
|
+
}
|
|
532
|
+
: null,
|
|
533
|
+
}
|
|
534
|
+
: null,
|
|
535
|
+
discoveryRule: 'When building a query, inspect table metadata first, then use relation propertyName and primary column from that metadata.',
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
539
|
+
},
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
server.tool(
|
|
543
|
+
'discover_script_contexts',
|
|
544
|
+
[
|
|
545
|
+
'Discover runtime script contexts and macro availability for handlers, hooks, flows, websocket scripts, GraphQL, packages, and extensions.',
|
|
546
|
+
'Use before writing dynamic JavaScript logic so the model does not mix context variables across surfaces.',
|
|
547
|
+
].join(' '),
|
|
548
|
+
{},
|
|
549
|
+
async () => {
|
|
550
|
+
const payload = {
|
|
551
|
+
transformer: {
|
|
552
|
+
rule: 'Dynamic server scripts are transformed before sandbox execution. Macros expand to $ctx paths; comments are not transformed.',
|
|
553
|
+
coreMacros: {
|
|
554
|
+
'@BODY': '$ctx.$body',
|
|
555
|
+
'@QUERY': '$ctx.$query',
|
|
556
|
+
'@PARAMS': '$ctx.$params',
|
|
557
|
+
'@USER': '$ctx.$user',
|
|
558
|
+
'@REPOS': '$ctx.$repos',
|
|
559
|
+
'@HELPERS': '$ctx.$helpers',
|
|
560
|
+
'@SOCKET': '$ctx.$socket',
|
|
561
|
+
'@DATA': '$ctx.$data',
|
|
562
|
+
'@STATUS': '$ctx.$statusCode',
|
|
563
|
+
'@ERROR': '$ctx.$error',
|
|
564
|
+
'@PKGS': '$ctx.$pkgs',
|
|
565
|
+
'@LOGS': '$ctx.$logs',
|
|
566
|
+
'@SHARE': '$ctx.$share',
|
|
567
|
+
'@TRIGGER(name,payload)': '$ctx.$trigger(name,payload)',
|
|
568
|
+
},
|
|
569
|
+
flowMacros: {
|
|
570
|
+
'@FLOW': '$ctx.$flow',
|
|
571
|
+
'@FLOW_PAYLOAD': '$ctx.$flow.$payload',
|
|
572
|
+
'@FLOW_LAST': '$ctx.$flow.$last',
|
|
573
|
+
'@FLOW_META': '$ctx.$flow.$meta',
|
|
574
|
+
'#table_name': '$ctx.$repos.table_name',
|
|
575
|
+
},
|
|
576
|
+
throws: '@THROW400 through @THROW503 and @THROW map to $ctx.$throw helpers.',
|
|
577
|
+
},
|
|
578
|
+
contexts: {
|
|
579
|
+
preHook: {
|
|
580
|
+
runs: 'Before handler.',
|
|
581
|
+
data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REPOS', '@HELPERS', '@THROW*', '@SOCKET emit helpers'],
|
|
582
|
+
returnBehavior: 'Returning a non-undefined value skips handler and becomes response data.',
|
|
583
|
+
},
|
|
584
|
+
handler: {
|
|
585
|
+
runs: 'Main route logic, or canonical CRUD if no handler overrides.',
|
|
586
|
+
data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REPOS.main', '@REPOS.secure', '@HELPERS', '@PKGS', '@SOCKET emit helpers', '@TRIGGER'],
|
|
587
|
+
returnBehavior: 'Return value becomes response body unless post-hook changes it.',
|
|
588
|
+
},
|
|
589
|
+
postHook: {
|
|
590
|
+
runs: 'After handler, including error path.',
|
|
591
|
+
data: ['@DATA', '@STATUS', '@ERROR', '@BODY', '@QUERY', '@USER', '@SHARE', '@API'],
|
|
592
|
+
returnBehavior: 'Mutate @DATA/$ctx.$data or return a non-undefined replacement response.',
|
|
593
|
+
},
|
|
594
|
+
flowStep: {
|
|
595
|
+
runs: 'Inside flow execution or admin flow step test.',
|
|
596
|
+
data: ['@FLOW_PAYLOAD', '@FLOW_LAST', '@FLOW', '@FLOW_META', '#table_name', '@HELPERS', '@SOCKET', '@TRIGGER'],
|
|
597
|
+
resultBehavior: 'Step return value is injected into @FLOW.<step.key> and @FLOW_LAST.',
|
|
598
|
+
branching: 'Condition steps use JavaScript truthy/falsy result; child branch is true/false.',
|
|
599
|
+
},
|
|
600
|
+
websocketConnection: {
|
|
601
|
+
runs: 'Socket.IO connection handler.',
|
|
602
|
+
data: ['@BODY connection info', '@USER if authenticated', '@SOCKET reply/join/leave/disconnect/emit helpers'],
|
|
603
|
+
},
|
|
604
|
+
websocketEvent: {
|
|
605
|
+
runs: 'Socket.IO event handler.',
|
|
606
|
+
data: ['@BODY event payload', '@USER if authenticated', '@SOCKET reply/join/leave/disconnect/emit helpers'],
|
|
607
|
+
resultBehavior: 'Client ack receives queued state first; handler result is emitted asynchronously as ws:result/ws:error with requestId.',
|
|
608
|
+
},
|
|
609
|
+
graphqlResolver: {
|
|
610
|
+
runs: 'Generated GraphQL resolver delegates to dynamic repo/query services.',
|
|
611
|
+
data: ['GraphQL request context', 'Bearer auth user', 'dynamic repositories'],
|
|
612
|
+
caveat: 'REST publishedMethods do not make GraphQL anonymous.',
|
|
613
|
+
},
|
|
614
|
+
extensionVueSfc: {
|
|
615
|
+
runs: 'Frontend extension code, not server sandbox.',
|
|
616
|
+
data: ['Vue/Nuxt composables', 'Enfyra composables', 'auto-resolved UI components'],
|
|
617
|
+
caveat: 'No import statements; save as extension_definition Vue SFC record.',
|
|
618
|
+
},
|
|
619
|
+
},
|
|
620
|
+
helpers: {
|
|
621
|
+
repos: '$repos.main enforces route main table behavior; $repos.secure.<table> enforces field permissions; $repos.<table> is trusted/internal.',
|
|
622
|
+
socketInHttpOrFlow: 'HTTP/flow context can emitToUser/emitToRoom/emitToGateway/broadcast, but cannot reply/join/leave/disconnect because there is no bound socket.',
|
|
623
|
+
packages: 'Server packages installed through install_package are exposed as $ctx.$pkgs.packageName in server scripts.',
|
|
624
|
+
files: 'Upload helpers are on $helpers; raw create_record on file_definition is not equivalent to multipart upload/storage rollback.',
|
|
625
|
+
},
|
|
626
|
+
adminTesting: {
|
|
627
|
+
flowStep: 'Use test_flow_step or run_admin_test(kind=flow_step).',
|
|
628
|
+
websocket: 'Use run_admin_test(kind=websocket_event|websocket_connection).',
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
633
|
+
},
|
|
634
|
+
);
|
|
635
|
+
|
|
53
636
|
// ============================================================================
|
|
54
637
|
// QUERY TOOLS
|
|
55
638
|
// ============================================================================
|
|
@@ -62,7 +645,7 @@ server.tool(
|
|
|
62
645
|
'Auth: publishedMethods on a route can allow a method without Bearer; otherwise JWT + routePermissions — see server instructions.',
|
|
63
646
|
'If path might differ from table name, use get_all_routes before asserting a URL.',
|
|
64
647
|
'Same mapping as MCP tool → HTTP: query_table=GET /table?..., create_record=POST /table, update_record=PATCH /table/id, delete_record=DELETE /table/id.',
|
|
65
|
-
'GraphQL: see graphqlHttpUrl / graphqlSchemaUrl in response;
|
|
648
|
+
'GraphQL: see graphqlHttpUrl / graphqlSchemaUrl in response; enable per table via gql_definition/update_table graphqlEnabled and send Bearer auth.',
|
|
66
649
|
].join(' '),
|
|
67
650
|
{},
|
|
68
651
|
async () => {
|
|
@@ -79,6 +662,7 @@ server.tool(
|
|
|
79
662
|
},
|
|
80
663
|
auth: {
|
|
81
664
|
publishedMethods: 'If the HTTP method is published for that route, no Bearer required; else Bearer JWT and routePermissions apply.',
|
|
665
|
+
graphql: 'GraphQL currently requires Bearer auth; route publishedMethods do not make GraphQL anonymous.',
|
|
82
666
|
mcp: 'This server uses admin credentials from env for tools (fetchAPI).',
|
|
83
667
|
},
|
|
84
668
|
pathResolution: 'Confirm route path with get_all_routes or metadata — path may not equal table name.',
|
|
@@ -173,6 +757,68 @@ server.tool('delete_record', 'Delete a record by ID', {
|
|
|
173
757
|
return { content: [{ type: 'text', text: `Record deleted:\n${JSON.stringify(result, null, 2)}` }] };
|
|
174
758
|
});
|
|
175
759
|
|
|
760
|
+
server.tool(
|
|
761
|
+
'run_admin_test',
|
|
762
|
+
[
|
|
763
|
+
'Run an Enfyra admin test without saving metadata. Wraps POST /admin/test/run.',
|
|
764
|
+
'Kinds: flow_step, websocket_event, websocket_connection. Use this to validate flow/websocket script behavior before creating records.',
|
|
765
|
+
].join(' '),
|
|
766
|
+
{
|
|
767
|
+
kind: z.enum(['flow_step', 'websocket_event', 'websocket_connection']).describe('Admin test kind'),
|
|
768
|
+
body: z.string().describe('JSON body for the test. Include type/config for flow_step or script/gatewayPath/eventName/payload for websocket tests. Do not include kind; the tool adds it.'),
|
|
769
|
+
},
|
|
770
|
+
async ({ kind, body }) => {
|
|
771
|
+
const parsed = body ? JSON.parse(body) : {};
|
|
772
|
+
const result = await fetchAPI(ENFYRA_API_URL, '/admin/test/run', {
|
|
773
|
+
method: 'POST',
|
|
774
|
+
body: JSON.stringify({ ...parsed, kind }),
|
|
775
|
+
});
|
|
776
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
777
|
+
},
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
server.tool(
|
|
781
|
+
'test_flow_step',
|
|
782
|
+
'Test a single flow step without saving it. Wraps POST /admin/flow/test-step.',
|
|
783
|
+
{
|
|
784
|
+
type: z.enum(['script', 'condition', 'query', 'create', 'update', 'delete', 'http', 'trigger_flow', 'sleep', 'log']).describe('Flow step type'),
|
|
785
|
+
config: z.string().describe('Step config as JSON string'),
|
|
786
|
+
timeout: z.number().optional().describe('Timeout in ms'),
|
|
787
|
+
key: z.string().optional().describe('Optional step key for mock flow context'),
|
|
788
|
+
mockFlow: z.string().optional().describe('Optional mockFlow JSON object'),
|
|
789
|
+
},
|
|
790
|
+
async ({ type, config, timeout, key, mockFlow }) => {
|
|
791
|
+
const body = {
|
|
792
|
+
type,
|
|
793
|
+
config: JSON.parse(config),
|
|
794
|
+
...(timeout ? { timeout } : {}),
|
|
795
|
+
...(key ? { key } : {}),
|
|
796
|
+
...(mockFlow ? { mockFlow: JSON.parse(mockFlow) } : {}),
|
|
797
|
+
};
|
|
798
|
+
const result = await fetchAPI(ENFYRA_API_URL, '/admin/flow/test-step', {
|
|
799
|
+
method: 'POST',
|
|
800
|
+
body: JSON.stringify(body),
|
|
801
|
+
});
|
|
802
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
803
|
+
},
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
server.tool(
|
|
807
|
+
'trigger_flow',
|
|
808
|
+
'Trigger a saved flow by id or name. Wraps POST /admin/flow/trigger/:id.',
|
|
809
|
+
{
|
|
810
|
+
flowIdOrName: z.union([z.string(), z.number()]).describe('Flow id or name accepted by FlowService.trigger'),
|
|
811
|
+
payload: z.string().optional().describe('Payload JSON object. Default {}.'),
|
|
812
|
+
},
|
|
813
|
+
async ({ flowIdOrName, payload }) => {
|
|
814
|
+
const result = await fetchAPI(ENFYRA_API_URL, `/admin/flow/trigger/${encodeURIComponent(String(flowIdOrName))}`, {
|
|
815
|
+
method: 'POST',
|
|
816
|
+
body: JSON.stringify({ payload: payload ? JSON.parse(payload) : {} }),
|
|
817
|
+
});
|
|
818
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
819
|
+
},
|
|
820
|
+
);
|
|
821
|
+
|
|
176
822
|
// ============================================================================
|
|
177
823
|
// ROUTE & HANDLER TOOLS
|
|
178
824
|
// ============================================================================
|
|
@@ -196,6 +842,319 @@ function resolveMethodIds(methodMap, names) {
|
|
|
196
842
|
});
|
|
197
843
|
}
|
|
198
844
|
|
|
845
|
+
async function getMethodIdNameMap() {
|
|
846
|
+
const methodMap = await getMethodMap();
|
|
847
|
+
return Object.fromEntries(Object.entries(methodMap).map(([method, id]) => [String(id), method]));
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function withMethodNames(records, methodIdNameMap, field = 'methods') {
|
|
851
|
+
return records.map((record) => ({
|
|
852
|
+
...record,
|
|
853
|
+
[field]: Array.isArray(record?.[field])
|
|
854
|
+
? record[field].map((item) => ({
|
|
855
|
+
...item,
|
|
856
|
+
method: item.method || methodIdNameMap[String(getId(item))] || null,
|
|
857
|
+
}))
|
|
858
|
+
: record?.[field],
|
|
859
|
+
}));
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
async function collectRestDefinitionState() {
|
|
863
|
+
await getValidToken();
|
|
864
|
+
const [
|
|
865
|
+
metadataContext,
|
|
866
|
+
routes,
|
|
867
|
+
handlers,
|
|
868
|
+
preHooks,
|
|
869
|
+
postHooks,
|
|
870
|
+
routePermissions,
|
|
871
|
+
guards,
|
|
872
|
+
guardRules,
|
|
873
|
+
fieldPermissions,
|
|
874
|
+
columnRules,
|
|
875
|
+
methodIdNameMap,
|
|
876
|
+
] = await Promise.all([
|
|
877
|
+
getMetadataTables(),
|
|
878
|
+
fetchAll('/route_definition?limit=1000'),
|
|
879
|
+
fetchAll('/route_handler_definition?limit=1000'),
|
|
880
|
+
fetchAll('/pre_hook_definition?limit=1000'),
|
|
881
|
+
fetchAll('/post_hook_definition?limit=1000'),
|
|
882
|
+
fetchAll('/route_permission_definition?limit=1000'),
|
|
883
|
+
fetchAll('/guard_definition?limit=1000'),
|
|
884
|
+
fetchAll('/guard_rule_definition?limit=1000'),
|
|
885
|
+
fetchAll('/field_permission_definition?limit=1000'),
|
|
886
|
+
fetchAll('/column_rule_definition?limit=1000'),
|
|
887
|
+
getMethodIdNameMap(),
|
|
888
|
+
]);
|
|
889
|
+
|
|
890
|
+
return {
|
|
891
|
+
...metadataContext,
|
|
892
|
+
routes,
|
|
893
|
+
handlers,
|
|
894
|
+
preHooks,
|
|
895
|
+
postHooks,
|
|
896
|
+
routePermissions,
|
|
897
|
+
guards,
|
|
898
|
+
guardRules,
|
|
899
|
+
fieldPermissions,
|
|
900
|
+
columnRules,
|
|
901
|
+
methodIdNameMap,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function enrichRoute(route, state) {
|
|
906
|
+
const routeId = getId(route);
|
|
907
|
+
const routeHandlers = state.handlers
|
|
908
|
+
.filter((item) => sameId(refId(item.route), routeId))
|
|
909
|
+
.map((item) => pickCodeSummary({
|
|
910
|
+
...item,
|
|
911
|
+
method: item.method ? { ...item.method, method: state.methodIdNameMap[String(getId(item.method))] || item.method.method || null } : item.method,
|
|
912
|
+
}, 'logic'));
|
|
913
|
+
const routePreHooks = withMethodNames(
|
|
914
|
+
state.preHooks.filter((item) => item.isGlobal || sameId(refId(item.route), routeId)),
|
|
915
|
+
state.methodIdNameMap,
|
|
916
|
+
).map((item) => pickCodeSummary(item, 'code'));
|
|
917
|
+
const routePostHooks = withMethodNames(
|
|
918
|
+
state.postHooks.filter((item) => item.isGlobal || sameId(refId(item.route), routeId)),
|
|
919
|
+
state.methodIdNameMap,
|
|
920
|
+
).map((item) => pickCodeSummary(item, 'code'));
|
|
921
|
+
const routePermissions = withMethodNames(
|
|
922
|
+
state.routePermissions.filter((item) => sameId(refId(item.route), routeId)),
|
|
923
|
+
state.methodIdNameMap,
|
|
924
|
+
);
|
|
925
|
+
const routeGuards = withMethodNames(
|
|
926
|
+
state.guards.filter((item) => item.isGlobal || sameId(refId(item.route), routeId)),
|
|
927
|
+
state.methodIdNameMap,
|
|
928
|
+
).map((guard) => ({
|
|
929
|
+
...guard,
|
|
930
|
+
rules: state.guardRules.filter((rule) => sameId(refId(rule.guard), getId(guard))),
|
|
931
|
+
}));
|
|
932
|
+
|
|
933
|
+
return {
|
|
934
|
+
...route,
|
|
935
|
+
availableMethods: Array.isArray(route.availableMethods)
|
|
936
|
+
? route.availableMethods.map((method) => ({ ...method, method: method.method || state.methodIdNameMap[String(getId(method))] || null }))
|
|
937
|
+
: route.availableMethods,
|
|
938
|
+
publishedMethods: Array.isArray(route.publishedMethods)
|
|
939
|
+
? route.publishedMethods.map((method) => ({ ...method, method: method.method || state.methodIdNameMap[String(getId(method))] || null }))
|
|
940
|
+
: route.publishedMethods,
|
|
941
|
+
skipRoleGuardMethods: Array.isArray(route.skipRoleGuardMethods)
|
|
942
|
+
? route.skipRoleGuardMethods.map((method) => ({ ...method, method: method.method || state.methodIdNameMap[String(getId(method))] || null }))
|
|
943
|
+
: route.skipRoleGuardMethods,
|
|
944
|
+
handlers: routeHandlers,
|
|
945
|
+
preHooks: routePreHooks,
|
|
946
|
+
postHooks: routePostHooks,
|
|
947
|
+
routePermissions,
|
|
948
|
+
guards: routeGuards,
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
server.tool(
|
|
953
|
+
'inspect_table',
|
|
954
|
+
[
|
|
955
|
+
'REST-first inspection for one table. Use before writing code, filters, permissions, validation, or routes for a table.',
|
|
956
|
+
'Returns columns, relations, route-backed REST paths, route handlers/hooks/guards/permissions, field permissions, and column validation rules.',
|
|
957
|
+
].join(' '),
|
|
958
|
+
{
|
|
959
|
+
tableName: z.string().describe('Table name or alias to inspect'),
|
|
960
|
+
},
|
|
961
|
+
async ({ tableName }) => {
|
|
962
|
+
let state = await collectRestDefinitionState();
|
|
963
|
+
let table = state.tables.find((item) => item?.name === tableName || item?.alias === tableName);
|
|
964
|
+
if (!table) {
|
|
965
|
+
await fetchAPI(ENFYRA_API_URL, '/admin/reload/metadata', { method: 'POST' }).catch(() => {});
|
|
966
|
+
await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
|
|
967
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
968
|
+
state = await collectRestDefinitionState();
|
|
969
|
+
table = state.tables.find((item) => item?.name === tableName || item?.alias === tableName);
|
|
970
|
+
}
|
|
971
|
+
if (!table) throw new Error(`Unknown table "${tableName}"`);
|
|
972
|
+
const tableId = getId(table);
|
|
973
|
+
const columnIds = new Set((table.columns || []).map((column) => String(getId(column))));
|
|
974
|
+
const relationIds = new Set((table.relations || []).map((relation) => String(getId(relation))));
|
|
975
|
+
const routes = state.routes.filter((route) => sameId(refId(route.mainTable), tableId));
|
|
976
|
+
|
|
977
|
+
const payload = {
|
|
978
|
+
table: summarizeTable(table),
|
|
979
|
+
database: getMetadataDatabaseContext(state.metadata, state.tables),
|
|
980
|
+
rest: {
|
|
981
|
+
routePattern: 'GET/POST /<path>; PATCH/DELETE /<path>/:id; no dynamic GET /<path>/:id.',
|
|
982
|
+
routes: routes.map((route) => enrichRoute(route, state)),
|
|
983
|
+
routeBacked: routes.length > 0,
|
|
984
|
+
},
|
|
985
|
+
validation: {
|
|
986
|
+
validateBody: table.validateBody,
|
|
987
|
+
columnRules: state.columnRules.filter((rule) => columnIds.has(String(refId(rule.column)))),
|
|
988
|
+
},
|
|
989
|
+
permissions: {
|
|
990
|
+
fieldPermissions: state.fieldPermissions.filter((permission) => (
|
|
991
|
+
permission.column && columnIds.has(String(refId(permission.column)))
|
|
992
|
+
) || (
|
|
993
|
+
permission.relation && relationIds.has(String(refId(permission.relation)))
|
|
994
|
+
)),
|
|
995
|
+
},
|
|
996
|
+
queryGuidance: {
|
|
997
|
+
fields: 'Use column names and relation propertyName values.',
|
|
998
|
+
filter: 'Use query DSL operators on column names or nested relation propertyName objects.',
|
|
999
|
+
deep: 'Deep fetch keys are relation propertyName values.',
|
|
1000
|
+
},
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
1004
|
+
},
|
|
1005
|
+
);
|
|
1006
|
+
|
|
1007
|
+
server.tool(
|
|
1008
|
+
'inspect_route',
|
|
1009
|
+
[
|
|
1010
|
+
'REST-first inspection for a route/path. Use before changing handlers, hooks, permissions, guards, or testing an endpoint.',
|
|
1011
|
+
'Returns the backing table, available/published methods, handlers, hooks, route permissions, guards, and exact REST URL pattern.',
|
|
1012
|
+
].join(' '),
|
|
1013
|
+
{
|
|
1014
|
+
path: z.string().optional().describe('Route path, e.g. /user_definition'),
|
|
1015
|
+
routeId: z.union([z.string(), z.number()]).optional().describe('route_definition id. Use either path or routeId.'),
|
|
1016
|
+
},
|
|
1017
|
+
async ({ path, routeId }) => {
|
|
1018
|
+
if (!path && !routeId) throw new Error('Provide path or routeId');
|
|
1019
|
+
const state = await collectRestDefinitionState();
|
|
1020
|
+
const route = state.routes.find((item) => (
|
|
1021
|
+
routeId ? sameId(getId(item), routeId) : item.path === normalizeRestPath(path)
|
|
1022
|
+
));
|
|
1023
|
+
if (!route) throw new Error(`Route not found: ${routeId || path}`);
|
|
1024
|
+
const table = state.tables.find((item) => sameId(getId(item), refId(route.mainTable))) || null;
|
|
1025
|
+
|
|
1026
|
+
const payload = {
|
|
1027
|
+
apiBase: ENFYRA_API_URL.replace(/\/$/, ''),
|
|
1028
|
+
route: enrichRoute(route, state),
|
|
1029
|
+
mainTable: summarizeTable(table),
|
|
1030
|
+
restPattern: {
|
|
1031
|
+
listOrCreate: `${ENFYRA_API_URL.replace(/\/$/, '')}${route.path}`,
|
|
1032
|
+
updateOrDelete: `${ENFYRA_API_URL.replace(/\/$/, '')}${route.path}/<id>`,
|
|
1033
|
+
oneById: `Use GET ${route.path}?filter=${JSON.stringify({ [getPrimaryColumn(table)?.name || 'id']: { _eq: '<id>' } })}&limit=1`,
|
|
1034
|
+
},
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
1038
|
+
},
|
|
1039
|
+
);
|
|
1040
|
+
|
|
1041
|
+
server.tool(
|
|
1042
|
+
'inspect_feature',
|
|
1043
|
+
[
|
|
1044
|
+
'Search live REST/system metadata for a feature name, route path, table, handler, hook, guard, or permission.',
|
|
1045
|
+
'Use when the user mentions a capability and you need to find where it lives before editing.',
|
|
1046
|
+
].join(' '),
|
|
1047
|
+
{
|
|
1048
|
+
query: z.string().describe('Feature keyword, table name, route path, handler text, hook name, or guard name'),
|
|
1049
|
+
},
|
|
1050
|
+
async ({ query }) => {
|
|
1051
|
+
const state = await collectRestDefinitionState();
|
|
1052
|
+
const q = query.toLowerCase();
|
|
1053
|
+
const matchesText = (value) => JSON.stringify(value ?? '').toLowerCase().includes(q);
|
|
1054
|
+
const tableMatches = state.tables.filter((table) => matchesText({
|
|
1055
|
+
name: table.name,
|
|
1056
|
+
alias: table.alias,
|
|
1057
|
+
description: table.description,
|
|
1058
|
+
columns: table.columns?.map((column) => ({ name: column.name, description: column.description })),
|
|
1059
|
+
relations: table.relations?.map((relation) => ({ propertyName: relation.propertyName, description: relation.description })),
|
|
1060
|
+
}));
|
|
1061
|
+
const routeMatches = state.routes.filter((route) => matchesText(route));
|
|
1062
|
+
const handlerMatches = state.handlers.filter((handler) => matchesText(handler)).map((item) => pickCodeSummary(item, 'logic'));
|
|
1063
|
+
const preHookMatches = state.preHooks.filter((hook) => matchesText(hook)).map((item) => pickCodeSummary(item, 'code'));
|
|
1064
|
+
const postHookMatches = state.postHooks.filter((hook) => matchesText(hook)).map((item) => pickCodeSummary(item, 'code'));
|
|
1065
|
+
const guardMatches = state.guards.filter((guard) => matchesText(guard));
|
|
1066
|
+
const permissionMatches = [
|
|
1067
|
+
...state.routePermissions.filter((permission) => matchesText(permission)).map((permission) => ({ type: 'route_permission', ...permission })),
|
|
1068
|
+
...state.fieldPermissions.filter((permission) => matchesText(permission)).map((permission) => ({ type: 'field_permission', ...permission })),
|
|
1069
|
+
];
|
|
1070
|
+
|
|
1071
|
+
const payload = {
|
|
1072
|
+
query,
|
|
1073
|
+
counts: {
|
|
1074
|
+
tables: tableMatches.length,
|
|
1075
|
+
routes: routeMatches.length,
|
|
1076
|
+
handlers: handlerMatches.length,
|
|
1077
|
+
preHooks: preHookMatches.length,
|
|
1078
|
+
postHooks: postHookMatches.length,
|
|
1079
|
+
guards: guardMatches.length,
|
|
1080
|
+
permissions: permissionMatches.length,
|
|
1081
|
+
},
|
|
1082
|
+
tables: tableMatches.map(summarizeTable).slice(0, 20),
|
|
1083
|
+
routes: routeMatches.map((route) => enrichRoute(route, state)).slice(0, 20),
|
|
1084
|
+
handlers: handlerMatches.slice(0, 20),
|
|
1085
|
+
preHooks: preHookMatches.slice(0, 20),
|
|
1086
|
+
postHooks: postHookMatches.slice(0, 20),
|
|
1087
|
+
guards: guardMatches.slice(0, 20),
|
|
1088
|
+
permissions: permissionMatches.slice(0, 20),
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
1092
|
+
},
|
|
1093
|
+
);
|
|
1094
|
+
|
|
1095
|
+
server.tool(
|
|
1096
|
+
'test_rest_endpoint',
|
|
1097
|
+
[
|
|
1098
|
+
'Execute a real REST request against the configured Enfyra API base.',
|
|
1099
|
+
'Use this after inspecting a route or changing handlers/hooks/guards. Pass paths like /table_definition?limit=1, not external URLs.',
|
|
1100
|
+
].join(' '),
|
|
1101
|
+
{
|
|
1102
|
+
method: z.enum(['GET', 'POST', 'PATCH', 'DELETE']).default('GET').describe('HTTP method'),
|
|
1103
|
+
path: z.string().describe('Enfyra API path, e.g. /route_definition?limit=1'),
|
|
1104
|
+
query: z.string().optional().describe('Optional query params JSON object, merged onto path query string'),
|
|
1105
|
+
body: z.string().optional().describe('Optional JSON request body string'),
|
|
1106
|
+
headers: z.string().optional().describe('Optional headers JSON object'),
|
|
1107
|
+
useAuth: z.boolean().optional().default(true).describe('Attach MCP admin Bearer token. Set false to test published/public access.'),
|
|
1108
|
+
},
|
|
1109
|
+
async ({ method, path, query, body, headers, useAuth }) => {
|
|
1110
|
+
const restPath = normalizeRestPath(path);
|
|
1111
|
+
const url = new URL(`${ENFYRA_API_URL.replace(/\/$/, '')}${restPath}`);
|
|
1112
|
+
const queryObj = parseJsonArg(query, {});
|
|
1113
|
+
for (const [key, value] of Object.entries(queryObj || {})) {
|
|
1114
|
+
url.searchParams.set(key, typeof value === 'string' ? value : JSON.stringify(value));
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const requestHeaders = {
|
|
1118
|
+
'Content-Type': 'application/json',
|
|
1119
|
+
...(parseJsonArg(headers, {}) || {}),
|
|
1120
|
+
};
|
|
1121
|
+
if (useAuth) {
|
|
1122
|
+
requestHeaders.Authorization = `Bearer ${await getValidToken()}`;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const started = Date.now();
|
|
1126
|
+
const response = await fetch(url, {
|
|
1127
|
+
method,
|
|
1128
|
+
headers: requestHeaders,
|
|
1129
|
+
...(body !== undefined && body !== null && method !== 'GET' ? { body } : {}),
|
|
1130
|
+
});
|
|
1131
|
+
const contentType = response.headers.get('content-type') || '';
|
|
1132
|
+
const responseText = await response.text();
|
|
1133
|
+
let parsedBody = responseText;
|
|
1134
|
+
if (contentType.includes('application/json') && responseText) {
|
|
1135
|
+
parsedBody = JSON.parse(responseText);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const payload = {
|
|
1139
|
+
request: {
|
|
1140
|
+
method,
|
|
1141
|
+
url: url.toString(),
|
|
1142
|
+
authenticated: !!useAuth,
|
|
1143
|
+
},
|
|
1144
|
+
response: {
|
|
1145
|
+
ok: response.ok,
|
|
1146
|
+
status: response.status,
|
|
1147
|
+
statusText: response.statusText,
|
|
1148
|
+
contentType,
|
|
1149
|
+
durationMs: Date.now() - started,
|
|
1150
|
+
body: parsedBody,
|
|
1151
|
+
},
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
1155
|
+
},
|
|
1156
|
+
);
|
|
1157
|
+
|
|
199
1158
|
server.tool('get_all_routes', 'List all route definitions (path, mainTable, handlers, hooks, permissions). Call before create_route to avoid duplicate paths and to pick routeId for hooks/handlers.', {
|
|
200
1159
|
includeDisabled: z.boolean().optional().default(false).describe('Include disabled routes'),
|
|
201
1160
|
}, async ({ includeDisabled }) => {
|
|
@@ -207,18 +1166,18 @@ server.tool('get_all_routes', 'List all route definitions (path, mainTable, hand
|
|
|
207
1166
|
server.tool(
|
|
208
1167
|
'create_route',
|
|
209
1168
|
[
|
|
210
|
-
'**Use this when the user wants a new API route or path** — not `create_table`. A route links a URL path to an existing table (`mainTableId`) and sets HTTP
|
|
1169
|
+
'**Use this when the user wants a new REST API route or path** — not `create_table`. A route links a URL path to an existing table (`mainTableId`) and sets HTTP methods.',
|
|
211
1170
|
'Do NOT create a new table_definition only to expose an endpoint; pick `mainTableId` from existing metadata unless the user explicitly needs new tables/columns.',
|
|
212
|
-
'availableMethods = which verbs the route responds to. publishedMethods = which verbs are public (no auth).',
|
|
1171
|
+
'availableMethods = which REST verbs the route responds to. publishedMethods = which REST verbs are public (no auth). GraphQL is enabled separately through gql_definition/update_table graphqlEnabled.',
|
|
213
1172
|
'After creation the tool auto-reloads routes. Then create handlers for specific methods via create_handler on this route id.',
|
|
214
1173
|
'Flow: resolve table id → create_route → create_handler (per method) → optionally create_pre_hook / create_post_hook → test via HTTP or admin test APIs (see server instructions).',
|
|
215
1174
|
].join(' '),
|
|
216
1175
|
{
|
|
217
1176
|
path: z.string().describe('URL path, must start with / (e.g., "/my-endpoint")'),
|
|
218
1177
|
mainTableId: z.union([z.string(), z.number()]).describe('ID of the table_definition this route operates on. The route\'s $repos.main will query this table.'),
|
|
219
|
-
methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'
|
|
220
|
-
.describe('HTTP
|
|
221
|
-
publishedMethods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'
|
|
1178
|
+
methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE']))
|
|
1179
|
+
.describe('HTTP methods this route supports (availableMethods). Common: ["GET","POST","PATCH","DELETE"]'),
|
|
1180
|
+
publishedMethods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'])).optional()
|
|
222
1181
|
.describe('Methods accessible WITHOUT auth token. Omit = all methods require auth.'),
|
|
223
1182
|
isEnabled: z.boolean().optional().default(true).describe('Enable route immediately'),
|
|
224
1183
|
description: z.string().optional().describe('Route description'),
|
|
@@ -260,7 +1219,7 @@ server.tool(
|
|
|
260
1219
|
].join(' '),
|
|
261
1220
|
{
|
|
262
1221
|
routeId: z.union([z.string(), z.number()]).describe('Route definition ID'),
|
|
263
|
-
methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'
|
|
1222
|
+
methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE']))
|
|
264
1223
|
.describe('Methods to create handlers for. Creates one handler per method.'),
|
|
265
1224
|
logic: z.string().describe('Handler JavaScript code'),
|
|
266
1225
|
timeout: z.number().optional().describe('Timeout in ms (default: system DEFAULT_HANDLER_TIMEOUT, usually 30000)'),
|
|
@@ -367,6 +1326,197 @@ server.tool(
|
|
|
367
1326
|
},
|
|
368
1327
|
);
|
|
369
1328
|
|
|
1329
|
+
server.tool(
|
|
1330
|
+
'create_column_rule',
|
|
1331
|
+
[
|
|
1332
|
+
'Create a REST body validation rule for a table column.',
|
|
1333
|
+
'Use inspect_table first to confirm validateBody, column type, and existing rules. Rule value is JSON; common shape is {"v": ...}.',
|
|
1334
|
+
].join(' '),
|
|
1335
|
+
{
|
|
1336
|
+
tableName: z.string().describe('Table name or alias'),
|
|
1337
|
+
columnName: z.string().describe('Column name'),
|
|
1338
|
+
ruleType: z.enum(['min', 'max', 'minLength', 'maxLength', 'pattern', 'format', 'minItems', 'maxItems', 'custom']).describe('Validation rule type'),
|
|
1339
|
+
value: z.string().optional().describe('Rule payload JSON, e.g. {"v":10} or {"v":"email"}'),
|
|
1340
|
+
message: z.string().optional().describe('Custom validation error message'),
|
|
1341
|
+
description: z.string().optional().describe('Admin note'),
|
|
1342
|
+
isEnabled: z.boolean().optional().default(true).describe('Enable the rule immediately'),
|
|
1343
|
+
},
|
|
1344
|
+
async ({ tableName, columnName, ruleType, value, message, description, isEnabled }) => {
|
|
1345
|
+
const { tables } = await getMetadataTables();
|
|
1346
|
+
const table = resolveTableOrThrow(tables, tableName);
|
|
1347
|
+
const column = resolveFieldOrThrow(table, columnName, 'column');
|
|
1348
|
+
const body = {
|
|
1349
|
+
ruleType,
|
|
1350
|
+
value: parseJsonArg(value, null),
|
|
1351
|
+
message,
|
|
1352
|
+
description,
|
|
1353
|
+
isEnabled,
|
|
1354
|
+
column: { id: getId(column) },
|
|
1355
|
+
};
|
|
1356
|
+
const result = await fetchAPI(ENFYRA_API_URL, '/column_rule_definition', {
|
|
1357
|
+
method: 'POST',
|
|
1358
|
+
body: JSON.stringify(body),
|
|
1359
|
+
});
|
|
1360
|
+
return { content: [{ type: 'text', text: `Column rule created for ${table.name}.${column.name}.\n${JSON.stringify(result, null, 2)}` }] };
|
|
1361
|
+
},
|
|
1362
|
+
);
|
|
1363
|
+
|
|
1364
|
+
server.tool(
|
|
1365
|
+
'create_field_permission',
|
|
1366
|
+
[
|
|
1367
|
+
'Create a field permission for one column or relation.',
|
|
1368
|
+
'Exactly one of columnName or relationName is required. Scope requires roleId or allowedUserIds. Conditions use the field permission condition DSL, not the full query DSL.',
|
|
1369
|
+
].join(' '),
|
|
1370
|
+
{
|
|
1371
|
+
tableName: z.string().describe('Table name or alias'),
|
|
1372
|
+
columnName: z.string().optional().describe('Column name to protect'),
|
|
1373
|
+
relationName: z.string().optional().describe('Relation propertyName to protect'),
|
|
1374
|
+
action: z.enum(['read', 'create', 'update']).default('read').describe('Action this permission applies to'),
|
|
1375
|
+
effect: z.enum(['allow', 'deny']).default('allow').describe('Allow or deny this field action'),
|
|
1376
|
+
roleId: z.union([z.string(), z.number()]).optional().describe('Role id scope'),
|
|
1377
|
+
allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Specific user ids scope'),
|
|
1378
|
+
condition: z.string().optional().describe('Optional condition JSON using field permission condition DSL'),
|
|
1379
|
+
description: z.string().optional().describe('Admin note'),
|
|
1380
|
+
isEnabled: z.boolean().optional().default(true).describe('Enable immediately'),
|
|
1381
|
+
},
|
|
1382
|
+
async ({ tableName, columnName, relationName, action, effect, roleId, allowedUserIds, condition, description, isEnabled }) => {
|
|
1383
|
+
if (!!columnName === !!relationName) throw new Error('Provide exactly one of columnName or relationName');
|
|
1384
|
+
if (!roleId && (!allowedUserIds || allowedUserIds.length === 0)) {
|
|
1385
|
+
throw new Error('Provide roleId or allowedUserIds');
|
|
1386
|
+
}
|
|
1387
|
+
const { tables } = await getMetadataTables();
|
|
1388
|
+
const table = resolveTableOrThrow(tables, tableName);
|
|
1389
|
+
const body = {
|
|
1390
|
+
isEnabled,
|
|
1391
|
+
description,
|
|
1392
|
+
action,
|
|
1393
|
+
effect,
|
|
1394
|
+
condition: parseJsonArg(condition, null),
|
|
1395
|
+
...(roleId ? { role: { id: roleId } } : {}),
|
|
1396
|
+
...(allowedUserIds?.length ? { allowedUsers: allowedUserIds.map((id) => ({ id })) } : {}),
|
|
1397
|
+
};
|
|
1398
|
+
if (columnName) {
|
|
1399
|
+
body.column = { id: getId(resolveFieldOrThrow(table, columnName, 'column')) };
|
|
1400
|
+
} else {
|
|
1401
|
+
body.relation = { id: getId(resolveFieldOrThrow(table, relationName, 'relation')) };
|
|
1402
|
+
}
|
|
1403
|
+
const result = await fetchAPI(ENFYRA_API_URL, '/field_permission_definition', {
|
|
1404
|
+
method: 'POST',
|
|
1405
|
+
body: JSON.stringify(body),
|
|
1406
|
+
});
|
|
1407
|
+
return { content: [{ type: 'text', text: `Field permission created on ${table.name}.${columnName || relationName}.\n${JSON.stringify(result, null, 2)}` }] };
|
|
1408
|
+
},
|
|
1409
|
+
);
|
|
1410
|
+
|
|
1411
|
+
server.tool(
|
|
1412
|
+
'create_route_permission',
|
|
1413
|
+
[
|
|
1414
|
+
'Create route access permission for a route and REST methods.',
|
|
1415
|
+
'Use this when a non-root role/user should access an authenticated route. publishedMethods are for public access; route permissions are for authenticated role/user access.',
|
|
1416
|
+
].join(' '),
|
|
1417
|
+
{
|
|
1418
|
+
path: z.string().optional().describe('Route path, e.g. /user_definition'),
|
|
1419
|
+
routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
|
|
1420
|
+
methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'])).describe('REST methods this permission allows'),
|
|
1421
|
+
roleId: z.union([z.string(), z.number()]).optional().describe('Role id scope'),
|
|
1422
|
+
allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Specific user ids scope'),
|
|
1423
|
+
description: z.string().optional().describe('Admin note'),
|
|
1424
|
+
isEnabled: z.boolean().optional().default(true).describe('Enable immediately'),
|
|
1425
|
+
},
|
|
1426
|
+
async ({ path, routeId, methods, roleId, allowedUserIds, description, isEnabled }) => {
|
|
1427
|
+
if (!path && !routeId) throw new Error('Provide path or routeId');
|
|
1428
|
+
if (!roleId && (!allowedUserIds || allowedUserIds.length === 0)) {
|
|
1429
|
+
throw new Error('Provide roleId or allowedUserIds');
|
|
1430
|
+
}
|
|
1431
|
+
const routes = await fetchAll('/route_definition?limit=1000');
|
|
1432
|
+
const route = routes.find((item) => (
|
|
1433
|
+
routeId ? sameId(getId(item), routeId) : item.path === normalizeRestPath(path)
|
|
1434
|
+
));
|
|
1435
|
+
if (!route) throw new Error(`Route not found: ${routeId || path}`);
|
|
1436
|
+
const methodMap = await getMethodMap();
|
|
1437
|
+
const body = {
|
|
1438
|
+
isEnabled,
|
|
1439
|
+
description,
|
|
1440
|
+
route: { id: getId(route) },
|
|
1441
|
+
methods: resolveMethodIds(methodMap, methods),
|
|
1442
|
+
...(roleId ? { role: { id: roleId } } : {}),
|
|
1443
|
+
...(allowedUserIds?.length ? { allowedUsers: allowedUserIds.map((id) => ({ id })) } : {}),
|
|
1444
|
+
};
|
|
1445
|
+
const result = await fetchAPI(ENFYRA_API_URL, '/route_permission_definition', {
|
|
1446
|
+
method: 'POST',
|
|
1447
|
+
body: JSON.stringify(body),
|
|
1448
|
+
});
|
|
1449
|
+
await fetchAPI(ENFYRA_API_URL, '/admin/reload/routes', { method: 'POST' }).catch(() => {});
|
|
1450
|
+
return { content: [{ type: 'text', text: `Route permission created for ${route.path}. Routes reloaded.\n${JSON.stringify(result, null, 2)}` }] };
|
|
1451
|
+
},
|
|
1452
|
+
);
|
|
1453
|
+
|
|
1454
|
+
server.tool(
|
|
1455
|
+
'create_guard',
|
|
1456
|
+
[
|
|
1457
|
+
'Create a metadata guard with optional rules for REST request gating.',
|
|
1458
|
+
'Root guards attach to route or global position. Rule configs: rate limits use {"maxRequests":number,"perSeconds":number}; IP lists use {"ips":["127.0.0.1"]}.',
|
|
1459
|
+
].join(' '),
|
|
1460
|
+
{
|
|
1461
|
+
name: z.string().describe('Guard name'),
|
|
1462
|
+
position: z.enum(['pre_auth', 'post_auth']).default('pre_auth').describe('Execution position for root guard'),
|
|
1463
|
+
routeId: z.union([z.string(), z.number()]).optional().describe('Optional route id'),
|
|
1464
|
+
path: z.string().optional().describe('Optional route path'),
|
|
1465
|
+
methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'])).optional().describe('Methods this guard applies to. Empty means all configured behavior for route/global.'),
|
|
1466
|
+
combinator: z.enum(['and', 'or']).default('and').describe('How child guards/rules combine'),
|
|
1467
|
+
priority: z.number().optional().default(0).describe('Lower runs first'),
|
|
1468
|
+
isGlobal: z.boolean().optional().default(false).describe('Apply globally instead of one route'),
|
|
1469
|
+
isEnabled: z.boolean().optional().default(false).describe('Enable immediately. Default false to avoid accidental lockout.'),
|
|
1470
|
+
description: z.string().optional().describe('Admin note'),
|
|
1471
|
+
rules: z.string().optional().describe('Optional rules JSON array: [{type, config, priority?, isEnabled?, description?, userIds?}]'),
|
|
1472
|
+
},
|
|
1473
|
+
async ({ name, position, routeId, path, methods, combinator, priority, isGlobal, isEnabled, description, rules }) => {
|
|
1474
|
+
let route = null;
|
|
1475
|
+
if (!isGlobal && (routeId || path)) {
|
|
1476
|
+
const routes = await fetchAll('/route_definition?limit=1000');
|
|
1477
|
+
route = routes.find((item) => (
|
|
1478
|
+
routeId ? sameId(getId(item), routeId) : item.path === normalizeRestPath(path)
|
|
1479
|
+
));
|
|
1480
|
+
if (!route) throw new Error(`Route not found: ${routeId || path}`);
|
|
1481
|
+
}
|
|
1482
|
+
const methodMap = await getMethodMap();
|
|
1483
|
+
const guardBody = {
|
|
1484
|
+
name,
|
|
1485
|
+
position,
|
|
1486
|
+
combinator,
|
|
1487
|
+
priority,
|
|
1488
|
+
isGlobal,
|
|
1489
|
+
isEnabled,
|
|
1490
|
+
description,
|
|
1491
|
+
...(route ? { route: { id: getId(route) } } : {}),
|
|
1492
|
+
...(methods?.length ? { methods: resolveMethodIds(methodMap, methods) } : {}),
|
|
1493
|
+
};
|
|
1494
|
+
const guard = await fetchAPI(ENFYRA_API_URL, '/guard_definition', {
|
|
1495
|
+
method: 'POST',
|
|
1496
|
+
body: JSON.stringify(guardBody),
|
|
1497
|
+
});
|
|
1498
|
+
const ruleInputs = parseJsonArg(rules, []);
|
|
1499
|
+
const createdRules = [];
|
|
1500
|
+
for (const rule of ruleInputs) {
|
|
1501
|
+
const ruleBody = {
|
|
1502
|
+
type: rule.type,
|
|
1503
|
+
config: rule.config,
|
|
1504
|
+
priority: rule.priority ?? 0,
|
|
1505
|
+
isEnabled: rule.isEnabled ?? true,
|
|
1506
|
+
description: rule.description,
|
|
1507
|
+
guard: { id: resultRecordId(guard) },
|
|
1508
|
+
...(Array.isArray(rule.userIds) && rule.userIds.length ? { users: rule.userIds.map((id) => ({ id })) } : {}),
|
|
1509
|
+
};
|
|
1510
|
+
createdRules.push(await fetchAPI(ENFYRA_API_URL, '/guard_rule_definition', {
|
|
1511
|
+
method: 'POST',
|
|
1512
|
+
body: JSON.stringify(ruleBody),
|
|
1513
|
+
}));
|
|
1514
|
+
}
|
|
1515
|
+
await fetchAPI(ENFYRA_API_URL, '/admin/reload/guards', { method: 'POST' }).catch(() => {});
|
|
1516
|
+
return { content: [{ type: 'text', text: `Guard created. Guard cache reloaded.\n${JSON.stringify({ guard, rules: createdRules }, null, 2)}` }] };
|
|
1517
|
+
},
|
|
1518
|
+
);
|
|
1519
|
+
|
|
370
1520
|
// Register table tools
|
|
371
1521
|
registerTableTools(server, ENFYRA_API_URL);
|
|
372
1522
|
|