@enfyra/mcp-server 0.0.106 → 0.0.108
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 -3
- package/package.json +1 -1
- package/src/lib/mcp-instructions.js +2 -2
- package/src/lib/mutation-guards.js +1 -1
- package/src/lib/platform-operation-tools.js +2 -18
- package/src/lib/table-tools.js +86 -63
- package/src/mcp-server-entry.mjs +6 -205
package/README.md
CHANGED
|
@@ -188,7 +188,7 @@ The MCP server includes safety guards for LLM callers:
|
|
|
188
188
|
- `validate_extension_code` checks Enfyra admin extension code through `/enfyra_extension/preview` without saving.
|
|
189
189
|
- `compiledCode` is generated from `sourceCode` and may differ textually because macros are expanded; the MCP server never accepts hand-written `compiledCode`.
|
|
190
190
|
- JSON responses include `compressionStats` with estimated token savings. Arrays of objects are converted to columnar form only when the compact shape is smaller than raw JSON.
|
|
191
|
-
- Relation tools reject physical FK/junction names.
|
|
191
|
+
- Relation tools reject physical FK/junction names and resolve table ids from exact table names or aliases before schema mutation.
|
|
192
192
|
- Generated code should use relation property names such as `conversation`, `sender`, and `member` instead of physical FK fields such as `conversationId`, `senderId`, or `memberId`.
|
|
193
193
|
- Custom route tools reject `mainTableId` unless the route is the canonical table route.
|
|
194
194
|
- Platform operation tools such as `api_endpoint_workflow`, `create_api_endpoint`, `enable_route`, `disable_route`, `delete_route`, `public_route_methods`, `add_route_methods`, `set_table_graphql`, `ensure_guard`, `ensure_field_permission`, `ensure_column_rule`, `ensure_websocket_event`, `ensure_script_flow_step`, `ensure_menu`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension` resolve metadata ids and validate code before saving.
|
|
@@ -197,7 +197,7 @@ The MCP server includes safety guards for LLM callers:
|
|
|
197
197
|
|
|
198
198
|
## Query Notes
|
|
199
199
|
|
|
200
|
-
Use explicit `fields` in read tools. Include mode is the default, such as `fields=id,email`. Any excluded field switches that scope to exclude mode: `fields=-compiledCode` returns all readable fields except `compiledCode`, and `fields=id,-compiledCode` still means all except `compiledCode`. Dotted exclusions such as `fields=-owner.avatar` work for relation fields when the relation exists in metadata. Every list/query call must pass either `limit` for a bounded page or `all: true` for a complete list. When a caller needs every matching row, pass `all: true` to `query_table` or `
|
|
200
|
+
Use explicit `fields` in read tools. Include mode is the default, such as `fields=id,email`. Any excluded field switches that scope to exclude mode: `fields=-compiledCode` returns all readable fields except `compiledCode`, and `fields=id,-compiledCode` still means all except `compiledCode`. Dotted exclusions such as `fields=-owner.avatar` work for relation fields when the relation exists in metadata. Every list/query call must pass either `limit` for a bounded page or `all: true` for a complete list. When a caller needs every matching row, pass `all: true` to `query_table`, `get_all_routes`, or `get_all_tables`; the tool should not choose an arbitrary page size like 30 or 50.
|
|
201
201
|
|
|
202
202
|
## Enfyra URL Pattern
|
|
203
203
|
|
|
@@ -231,7 +231,7 @@ Do not create custom login/logout/me routes that manually set Enfyra token cooki
|
|
|
231
231
|
|
|
232
232
|
The MCP server exposes tools for metadata discovery, examples, query/CRUD, method management, route lifecycle, route access audit/grant, routes, handlers, hooks, tables, columns, relations, cache reloads, logs, users, roles, packages, menus, extensions, scripts, flows, websocket, files, and `get_enfyra_api_context`.
|
|
233
233
|
|
|
234
|
-
Routes have two separate controls. `isEnabled` controls runtime registration: disabled routes return `404`. Use `enable_route` and `disable_route` for this lifecycle. `publicMethods` controls anonymous access for enabled routes; use `public_route_methods
|
|
234
|
+
Routes have two separate controls. `isEnabled` controls runtime registration: disabled routes return `404`. Use `enable_route` and `disable_route` for this lifecycle. `publicMethods` controls anonymous access for enabled routes; use `public_route_methods` and `private_route_methods` for that access boundary.
|
|
235
235
|
|
|
236
236
|
For authenticated route access, use `audit_route_access` before changing permissions and `ensure_route_access` to grant access by route path plus role/user. For production script edits, use `trace_metadata_usage`, `get_script_source`, and `patch_script_source` so changes are targeted, hash-checked, and validated.
|
|
237
237
|
|
package/package.json
CHANGED
|
@@ -36,7 +36,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
36
36
|
'',
|
|
37
37
|
'### Core Contracts',
|
|
38
38
|
'- Tool JSON responses use `responseFormat: "json+columnar-v1"`. Large arrays of objects may be encoded as `{ format: "columnar-v1", columns: [...], rows: [[...]], rowCount }` only when that is smaller than raw JSON; read each row value by matching `columns[index]` to `rows[n][index]`. Do not guess object keys inside `rows`. `compressionStats` estimates token savings and includes whether compression was applied; use it only when the user asks about savings.',
|
|
39
|
-
'- `query_table` and `
|
|
39
|
+
'- `query_table`, `get_all_routes`, and `get_all_tables` require explicit intent: pass `limit` for bounded reads or `all: true` for a complete list. Do not invent arbitrary limits such as 30 or 50.',
|
|
40
40
|
'- Read tools are minimal by default. Pass explicit `fields`; use metadata inspection before guessing field/relation names. Field exclusion mode exists: `fields=-compiledCode`, and `fields=id,-compiledCode` still means all readable fields except `compiledCode`.',
|
|
41
41
|
'- Mutations return ids/status by default. Re-read with `find_one_record` or `query_table` and explicit `fields` when the saved row matters.',
|
|
42
42
|
'- Dynamic repository reads use `filter`, not `where`: `@REPOS.table.find({ filter: {...} })`, `#table.find({ filter: {...} })`, and `exists(filter)`.',
|
|
@@ -60,7 +60,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
60
60
|
'',
|
|
61
61
|
'### Direct HTTP Mapping',
|
|
62
62
|
'- Route-backed table CRUD is REST: `GET /<table>?...`, `POST /<table>`, `PATCH /<table>/<id>`, `DELETE /<table>/<id>`. There is no `GET /<table>/<id>`; use a filtered list with `limit=1` or `find_one_record`.',
|
|
63
|
-
'- REST route lifecycle is controlled by `enfyra_route.isEnabled`: disabled routes are not registered at runtime and return 404. Use `enable_route`/`disable_route` instead of raw route PATCH. REST public access is controlled by route `publicMethods`; otherwise direct HTTP needs Bearer JWT plus route permissions. GraphQL requires Bearer auth and table GraphQL enablement.',
|
|
63
|
+
'- REST route lifecycle is controlled by `enfyra_route.isEnabled`: disabled routes are not registered at runtime and return 404. Use `enable_route`/`disable_route` instead of raw route PATCH. REST public access is controlled by route `publicMethods`; otherwise direct HTTP needs Bearer JWT plus route permissions. GraphQL table data requires Bearer auth and table GraphQL enablement; anonymous root/schema probes may still return a 200 without exposing table data.',
|
|
64
64
|
'',
|
|
65
65
|
'When the user asks for details, fetch only the relevant live context or example category instead of relying on broad memorized rules.',
|
|
66
66
|
].join('\n');
|
|
@@ -72,7 +72,7 @@ export function rejectUnsafeRelationDefinitionPayload(tableName, payload) {
|
|
|
72
72
|
if (forbidden.length > 0) {
|
|
73
73
|
throw new Error(
|
|
74
74
|
`Do not send physical FK/junction fields to enfyra_relation: ${forbidden.join(', ')}. ` +
|
|
75
|
-
'Use create_relation
|
|
75
|
+
'Use create_relation with targetTable/type/propertyName; Enfyra derives physical columns.'
|
|
76
76
|
);
|
|
77
77
|
}
|
|
78
78
|
}
|
|
@@ -871,8 +871,8 @@ async function runApiEndpointWorkflow(apiUrl, opts) {
|
|
|
871
871
|
nextSteps,
|
|
872
872
|
cleanupHints: latestState.endpoint.routeId
|
|
873
873
|
? [
|
|
874
|
-
`
|
|
875
|
-
`
|
|
874
|
+
`Use delete_route({ routeId: ${JSON.stringify(latestState.endpoint.routeId)}, confirm: false }) to preview route-owned handlers, hooks, guards, and permissions before cleanup.`,
|
|
875
|
+
`Then call delete_route({ routeId: ${JSON.stringify(latestState.endpoint.routeId)}, expectedPath: ${JSON.stringify(latestState.endpoint.path)}, confirm: true }) when the route contract is no longer needed.`,
|
|
876
876
|
]
|
|
877
877
|
: [],
|
|
878
878
|
};
|
|
@@ -1047,22 +1047,6 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
1047
1047
|
})),
|
|
1048
1048
|
);
|
|
1049
1049
|
|
|
1050
|
-
server.tool(
|
|
1051
|
-
'set_public_route_methods',
|
|
1052
|
-
'Business operation: replace a route publicMethods list exactly.',
|
|
1053
|
-
{
|
|
1054
|
-
path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
|
|
1055
|
-
routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
|
|
1056
|
-
methods: z.array(z.string()).describe('Exact HTTP method names that should be public. Use an empty array to make all methods private.'),
|
|
1057
|
-
},
|
|
1058
|
-
async ({ path, routeId, methods }) => jsonText(await updateRoutePublicMethods(ENFYRA_API_URL, {
|
|
1059
|
-
path,
|
|
1060
|
-
routeId,
|
|
1061
|
-
methods,
|
|
1062
|
-
mode: 'replace',
|
|
1063
|
-
})),
|
|
1064
|
-
);
|
|
1065
|
-
|
|
1066
1050
|
server.tool(
|
|
1067
1051
|
'private_route_methods',
|
|
1068
1052
|
'Business operation: make specific public route methods private again.',
|
package/src/lib/table-tools.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { fetchAPI } from './fetch.js';
|
|
6
|
+
import { jsonContent } from './response-format.js';
|
|
6
7
|
|
|
7
8
|
let schemaQueue = Promise.resolve();
|
|
8
9
|
|
|
@@ -43,6 +44,19 @@ export function resolveTableFromMetadataByName(metadata, tableName) {
|
|
|
43
44
|
.find((table) => table?.name === tableName || table?.alias === tableName) || null;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
export function resolveTableIdentifierFromMetadata(metadata, tableRef, label = 'table') {
|
|
48
|
+
const resolvedTable = normalizeTablesFromMetadata(metadata)
|
|
49
|
+
.find((table) => (
|
|
50
|
+
String(getId(table)) === String(tableRef) ||
|
|
51
|
+
table?.name === tableRef ||
|
|
52
|
+
table?.alias === tableRef
|
|
53
|
+
));
|
|
54
|
+
if (!resolvedTable) {
|
|
55
|
+
throw new Error(`${label} "${tableRef}" was not found in metadata. Pass an existing table id, name, or alias from get_all_tables/inspect_table.`);
|
|
56
|
+
}
|
|
57
|
+
return getId(resolvedTable);
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
/**
|
|
47
61
|
* Helper: fetch table with full columns and relations.
|
|
48
62
|
* Dynamic enfyra_table relation fields can be paginated/truncated, so schema
|
|
@@ -147,6 +161,14 @@ export function normalizeRelationForTablePatch(relation) {
|
|
|
147
161
|
return normalized;
|
|
148
162
|
}
|
|
149
163
|
|
|
164
|
+
function assertNoForbiddenRelationKeys(args) {
|
|
165
|
+
for (const key of FORBIDDEN_RELATION_KEYS) {
|
|
166
|
+
if (Object.prototype.hasOwnProperty.call(args, key)) {
|
|
167
|
+
throw new Error(`create_relation must not include physical column field "${key}". Use sourceTableId/targetTableId and relation propertyName only; Enfyra derives FK and junction columns.`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
150
172
|
export function sanitizeExistingRelationForTablePatch(relation) {
|
|
151
173
|
const {
|
|
152
174
|
fkCol,
|
|
@@ -307,27 +329,32 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
307
329
|
});
|
|
308
330
|
}
|
|
309
331
|
|
|
310
|
-
async function appendRelationToTable(
|
|
332
|
+
async function appendRelationToTable(args) {
|
|
311
333
|
return withSchemaQueue(async () => {
|
|
312
|
-
|
|
334
|
+
assertNoForbiddenRelationKeys(args);
|
|
335
|
+
const { sourceTableId, targetTableId, type, propertyName, inversePropertyName, mappedBy, isNullable, onDelete, description } = args;
|
|
336
|
+
const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
|
|
337
|
+
const resolvedSourceTableId = resolveTableIdentifierFromMetadata(metadata, sourceTableId, 'sourceTableId');
|
|
338
|
+
const resolvedTargetTableId = resolveTableIdentifierFromMetadata(metadata, targetTableId, 'targetTableId');
|
|
339
|
+
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, resolvedSourceTableId);
|
|
313
340
|
if (!tableData) {
|
|
314
|
-
return { content: [{ type: 'text', text: `Error: Table
|
|
341
|
+
return { content: [{ type: 'text', text: `Error: Table ${sourceTableId} not found.` }] };
|
|
315
342
|
}
|
|
316
343
|
const existingRelations = (tableData.relations || []).map(sanitizeExistingRelationForTablePatch);
|
|
317
344
|
const beforeIds = existingRelations.map((relation) => String(getId(relation))).filter((id) => id !== 'null');
|
|
318
|
-
const newRelation = { targetTable:
|
|
345
|
+
const newRelation = { targetTable: resolvedTargetTableId, type, propertyName };
|
|
319
346
|
if (inversePropertyName !== undefined) newRelation.inversePropertyName = inversePropertyName || null;
|
|
320
347
|
if (mappedBy !== undefined) newRelation.mappedBy = mappedBy;
|
|
321
348
|
if (isNullable !== undefined) newRelation.isNullable = isNullable;
|
|
322
349
|
if (onDelete !== undefined) newRelation.onDelete = onDelete;
|
|
323
350
|
if (description !== undefined) newRelation.description = description;
|
|
324
|
-
const result = await patchTableAutoConfirm(ENFYRA_API_URL,
|
|
325
|
-
await verifyRelationCascade(ENFYRA_API_URL,
|
|
351
|
+
const result = await patchTableAutoConfirm(ENFYRA_API_URL, resolvedSourceTableId, { relations: [...existingRelations, newRelation] });
|
|
352
|
+
await verifyRelationCascade(ENFYRA_API_URL, resolvedSourceTableId, beforeIds, {
|
|
326
353
|
action: 'create',
|
|
327
354
|
propertyName,
|
|
328
355
|
});
|
|
329
356
|
return {
|
|
330
|
-
content: [{ type: 'text', text: `Relation created: ${propertyName} (${type}) from table ${
|
|
357
|
+
content: [{ type: 'text', text: `Relation created: ${propertyName} (${type}) from table ${resolvedSourceTableId} → ${resolvedTargetTableId}.\n\nFull result:\n${JSON.stringify(result, null, 2)}` }],
|
|
331
358
|
};
|
|
332
359
|
});
|
|
333
360
|
}
|
|
@@ -354,7 +381,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
354
381
|
targetColumn: target,
|
|
355
382
|
preservedColumnIds: beforeIds.filter((id) => id !== String(columnId)),
|
|
356
383
|
destructive: true,
|
|
357
|
-
next: 'Call delete_column
|
|
384
|
+
next: 'Call delete_column again with confirm=true to drop the physical column and metadata.',
|
|
358
385
|
}, null, 2) }],
|
|
359
386
|
};
|
|
360
387
|
}
|
|
@@ -396,7 +423,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
396
423
|
targetRelation: target,
|
|
397
424
|
preservedRelationIds: beforeIds.filter((id) => id !== String(relationId)),
|
|
398
425
|
destructive: true,
|
|
399
|
-
next: 'Call delete_relation
|
|
426
|
+
next: 'Call delete_relation again with confirm=true to drop relation metadata and any derived FK/junction structures.',
|
|
400
427
|
}, null, 2) }],
|
|
401
428
|
};
|
|
402
429
|
}
|
|
@@ -434,8 +461,8 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
434
461
|
};
|
|
435
462
|
|
|
436
463
|
const relationCreateSchema = {
|
|
437
|
-
sourceTableId: z.string().describe('Source table
|
|
438
|
-
targetTableId: z.string().describe('Target table
|
|
464
|
+
sourceTableId: z.string().describe('Source table id, exact table name, or alias. For many-to-one, this is the table that owns the relation property.'),
|
|
465
|
+
targetTableId: z.string().describe('Target table id, exact table name, or alias. MCP resolves names/aliases to ids before mutation.'),
|
|
439
466
|
type: z.enum(['many-to-one', 'one-to-many', 'one-to-one', 'many-to-many']).describe('Relation type.'),
|
|
440
467
|
propertyName: z.string().describe('Property name on source table (e.g., "customer", "items").'),
|
|
441
468
|
inversePropertyName: z.string().optional().describe('Property name on target table for bidirectional relation (e.g., "orders"). Omit unless the reverse field is truly needed.'),
|
|
@@ -443,6 +470,13 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
443
470
|
isNullable: z.boolean().optional().default(true).describe('Whether the relation is nullable.'),
|
|
444
471
|
onDelete: z.enum(['CASCADE', 'SET NULL', 'RESTRICT']).optional().default('SET NULL').describe('On delete behavior.'),
|
|
445
472
|
description: z.string().optional().describe('Relation description.'),
|
|
473
|
+
fkCol: z.never().optional().describe('Forbidden. Use propertyName only; Enfyra derives FK columns.'),
|
|
474
|
+
fkColumn: z.never().optional().describe('Forbidden. Use propertyName only; Enfyra derives FK columns.'),
|
|
475
|
+
foreignKeyColumn: z.never().optional().describe('Forbidden. Use propertyName only; Enfyra derives FK columns.'),
|
|
476
|
+
sourceColumn: z.never().optional().describe('Forbidden. Use propertyName only; Enfyra derives FK columns.'),
|
|
477
|
+
targetColumn: z.never().optional().describe('Forbidden. Use propertyName only; Enfyra derives FK columns.'),
|
|
478
|
+
junctionSourceColumn: z.never().optional().describe('Forbidden. Use relation property names only; Enfyra derives junction columns.'),
|
|
479
|
+
junctionTargetColumn: z.never().optional().describe('Forbidden. Use relation property names only; Enfyra derives junction columns.'),
|
|
446
480
|
};
|
|
447
481
|
|
|
448
482
|
const columnDeleteSchema = {
|
|
@@ -461,13 +495,45 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
461
495
|
|
|
462
496
|
server.tool(
|
|
463
497
|
'get_all_tables',
|
|
464
|
-
'
|
|
465
|
-
{
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
498
|
+
'List table definitions from metadata. Every call must pass either limit or all=true. Use search to narrow by table name or alias.',
|
|
499
|
+
{
|
|
500
|
+
limit: z.number().int().positive().optional().describe('Maximum tables returned after search. Required unless all=true.'),
|
|
501
|
+
all: z.boolean().optional().describe('Return all matched tables. Use this when a complete table list is required.'),
|
|
502
|
+
search: z.string().optional().describe('Optional table name, alias, or description substring filter.'),
|
|
503
|
+
},
|
|
504
|
+
async ({ limit, all, search }) => {
|
|
505
|
+
if (!all && limit === undefined) {
|
|
506
|
+
throw new Error('get_all_tables requires either limit or all=true. Do not invent arbitrary limits for complete table lists; use all=true.');
|
|
507
|
+
}
|
|
508
|
+
const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
|
|
509
|
+
const needle = search?.trim().toLowerCase();
|
|
510
|
+
const tables = normalizeTablesFromMetadata(metadata)
|
|
511
|
+
.map((table) => ({
|
|
512
|
+
id: getId(table),
|
|
513
|
+
name: table.name ?? null,
|
|
514
|
+
alias: table.alias ?? null,
|
|
515
|
+
description: table.description ?? null,
|
|
516
|
+
isSingleRecord: table.isSingleRecord ?? null,
|
|
517
|
+
columnCount: Array.isArray(table.columns) ? table.columns.length : null,
|
|
518
|
+
relationCount: Array.isArray(table.relations) ? table.relations.length : null,
|
|
519
|
+
routeBacked: Boolean(table.route || table.routeId || table.path),
|
|
520
|
+
}))
|
|
521
|
+
.filter((table) => {
|
|
522
|
+
if (!needle) return true;
|
|
523
|
+
return [table.name, table.alias, table.description]
|
|
524
|
+
.some((value) => String(value || '').toLowerCase().includes(needle));
|
|
525
|
+
});
|
|
526
|
+
const returnedTables = all ? tables : tables.slice(0, limit);
|
|
527
|
+
return jsonContent({
|
|
528
|
+
action: 'get_all_tables',
|
|
529
|
+
totalTableCount: normalizeTablesFromMetadata(metadata).length,
|
|
530
|
+
matchedTableCount: tables.length,
|
|
531
|
+
returnedTableCount: returnedTables.length,
|
|
532
|
+
all: Boolean(all),
|
|
533
|
+
search: search || null,
|
|
534
|
+
tables: returnedTables,
|
|
535
|
+
detailHint: 'Use inspect_table with a table id/name for columns, relations, indexes, routes, permissions, and GraphQL state.',
|
|
536
|
+
});
|
|
471
537
|
}
|
|
472
538
|
);
|
|
473
539
|
|
|
@@ -558,7 +624,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
558
624
|
alias: z.string().optional().describe('New table alias.'),
|
|
559
625
|
description: z.string().optional().describe('New description.'),
|
|
560
626
|
isSingleRecord: z.boolean().optional().describe('Set to true for single-record table (e.g., settings/config).'),
|
|
561
|
-
graphqlEnabled: z.boolean().optional().describe('Enable or disable GraphQL for this table by syncing enfyra_graphql.isEnabled. GraphQL still requires Bearer auth.'),
|
|
627
|
+
graphqlEnabled: z.boolean().optional().describe('Enable or disable GraphQL for this table by syncing enfyra_graphql.isEnabled. GraphQL table data still requires Bearer auth; anonymous root or schema probes may return 200.'),
|
|
562
628
|
indexes: z.string().optional().describe('Complete JSON array of logical index field groups to store on enfyra_table.indexes. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Omit to preserve current indexes; pass [] to clear.'),
|
|
563
629
|
uniques: z.string().optional().describe('Complete JSON array of logical unique field groups to store on enfyra_table.uniques. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Omit to preserve current uniques; pass [] to clear.'),
|
|
564
630
|
},
|
|
@@ -633,18 +699,6 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
633
699
|
appendColumnToTable
|
|
634
700
|
);
|
|
635
701
|
|
|
636
|
-
server.tool(
|
|
637
|
-
'add_column',
|
|
638
|
-
[
|
|
639
|
-
'Alias for create_column. Add a column to an existing table through the canonical enfyra_table cascade.',
|
|
640
|
-
'Use this for schema additions, including hidden secret fields with isPublished=false.',
|
|
641
|
-
'Reads full table metadata and skips non-persisted generated/derived column metadata without id/_id when rebuilding the table columns payload.',
|
|
642
|
-
'Run schema changes sequentially — migration locks DB per operation.',
|
|
643
|
-
].join(' '),
|
|
644
|
-
columnCreateSchema,
|
|
645
|
-
appendColumnToTable
|
|
646
|
-
);
|
|
647
|
-
|
|
648
702
|
// ─── UPDATE COLUMN ───
|
|
649
703
|
|
|
650
704
|
server.tool(
|
|
@@ -722,24 +776,13 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
722
776
|
removeColumnFromTable
|
|
723
777
|
);
|
|
724
778
|
|
|
725
|
-
server.tool(
|
|
726
|
-
'remove_column',
|
|
727
|
-
[
|
|
728
|
-
'Alias for delete_column. Remove a column through the canonical enfyra_table cascade.',
|
|
729
|
-
'This drops the physical column. Confirm destructive schema changes before calling.',
|
|
730
|
-
'Reads full table metadata and skips non-persisted generated/derived column metadata without id/_id when rebuilding the table columns payload.',
|
|
731
|
-
'Run schema changes sequentially — migration locks DB per operation.',
|
|
732
|
-
].join(' '),
|
|
733
|
-
columnDeleteSchema,
|
|
734
|
-
removeColumnFromTable
|
|
735
|
-
);
|
|
736
|
-
|
|
737
779
|
// ─── CREATE RELATION ───
|
|
738
780
|
|
|
739
781
|
server.tool(
|
|
740
782
|
'create_relation',
|
|
741
783
|
[
|
|
742
784
|
'Create a relation between two tables (many-to-one, one-to-many, one-to-one, many-to-many).',
|
|
785
|
+
'sourceTableId and targetTableId may be table ids, exact table names, or aliases; MCP resolves them from metadata before mutation.',
|
|
743
786
|
'For many-to-one: a physical FK column is created on the source table. For one-to-many: the FK is on the target (inverse relation). This physical FK is derived by Enfyra and hidden from app schema/forms.',
|
|
744
787
|
'Never ask the user for physical FK column names and never send fkCol/fkColumn/foreignKeyColumn/sourceColumn/targetColumn/junction*Column. The public API uses relation propertyName only.',
|
|
745
788
|
'Run sequentially — DB migration locks per operation.',
|
|
@@ -750,17 +793,6 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
750
793
|
appendRelationToTable
|
|
751
794
|
);
|
|
752
795
|
|
|
753
|
-
server.tool(
|
|
754
|
-
'add_relation',
|
|
755
|
-
[
|
|
756
|
-
'Alias for create_relation. Add a relation through the canonical enfyra_table cascade.',
|
|
757
|
-
'Use relation propertyName only; never provide physical FK or junction column names.',
|
|
758
|
-
'Run schema changes sequentially — migration locks DB per operation.',
|
|
759
|
-
].join(' '),
|
|
760
|
-
relationCreateSchema,
|
|
761
|
-
appendRelationToTable
|
|
762
|
-
);
|
|
763
|
-
|
|
764
796
|
// ─── DELETE RELATION ───
|
|
765
797
|
|
|
766
798
|
server.tool(
|
|
@@ -776,13 +808,4 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
776
808
|
removeRelationFromTable
|
|
777
809
|
);
|
|
778
810
|
|
|
779
|
-
server.tool(
|
|
780
|
-
'remove_relation',
|
|
781
|
-
[
|
|
782
|
-
'Alias for delete_relation. Remove a relation through the canonical enfyra_table cascade.',
|
|
783
|
-
'This can drop FK columns or junction tables. Confirm destructive schema changes before calling.',
|
|
784
|
-
].join(' '),
|
|
785
|
-
relationDeleteSchema,
|
|
786
|
-
removeRelationFromTable
|
|
787
|
-
);
|
|
788
811
|
}
|
package/src/mcp-server-entry.mjs
CHANGED
|
@@ -64,7 +64,7 @@ const CAPABILITY_AREAS = [
|
|
|
64
64
|
{
|
|
65
65
|
area: 'GraphQL',
|
|
66
66
|
tables: ['enfyra_graphql'],
|
|
67
|
-
workflow: 'Enable per table through enfyra_graphql or update_table graphqlEnabled. GraphQL requires Bearer auth.',
|
|
67
|
+
workflow: 'Enable per table through enfyra_graphql or update_table graphqlEnabled. GraphQL table data requires Bearer auth; anonymous root or schema probes may return 200 without exposing table data.',
|
|
68
68
|
},
|
|
69
69
|
{
|
|
70
70
|
area: 'Files and storage',
|
|
@@ -746,7 +746,7 @@ server.tool(
|
|
|
746
746
|
endpoint: `${ENFYRA_API_URL.replace(/\/$/, '')}/graphql`,
|
|
747
747
|
schemaEndpoint: `${ENFYRA_API_URL.replace(/\/$/, '')}/graphql-schema`,
|
|
748
748
|
enablement: 'A table appears in GraphQL when enfyra_graphql has an enabled row for that table. REST route availableMethods does not enable GraphQL.',
|
|
749
|
-
auth: 'GraphQL
|
|
749
|
+
auth: 'GraphQL table data requires Authorization: Bearer <accessToken>; REST publicMethods do not make GraphQL table data anonymous. Anonymous root/schema probes may still return 200.',
|
|
750
750
|
management: routeTables.has('enfyra_graphql')
|
|
751
751
|
? 'Use update_table graphqlEnabled or create/update records on enfyra_graphql, then reload_graphql if needed.'
|
|
752
752
|
: 'Use update_table graphqlEnabled, then reload_graphql if needed.',
|
|
@@ -914,7 +914,7 @@ server.tool(
|
|
|
914
914
|
: 'SQL commonly uses id; Mongo uses _id. Use table metadata primary column when available.',
|
|
915
915
|
relationNames: 'API relation operations use relation propertyName, not physical FK column names.',
|
|
916
916
|
relationCascadeFkContract: 'When creating relations through create_table/create_relation/enfyra_table PATCH, never provide fkCol/fkColumn/foreignKeyColumn/sourceColumn/targetColumn/junction*Column. These are physical implementation details derived by Enfyra and hidden from app schema/forms.',
|
|
917
|
-
graphql: 'GraphQL query args also accept filter/sort/page/limit
|
|
917
|
+
graphql: 'GraphQL query args also accept filter/sort/page/limit. Table data requires Bearer auth and table enablement via enfyra_graphql; anonymous root/schema probes may still return 200.',
|
|
918
918
|
},
|
|
919
919
|
table: tableName
|
|
920
920
|
? {
|
|
@@ -1052,7 +1052,7 @@ server.tool(
|
|
|
1052
1052
|
graphqlResolver: {
|
|
1053
1053
|
runs: 'Generated GraphQL resolver delegates to dynamic repo/query services.',
|
|
1054
1054
|
data: ['GraphQL request context', 'Bearer auth user', 'dynamic repositories'],
|
|
1055
|
-
caveat: 'REST publicMethods do not make GraphQL anonymous.',
|
|
1055
|
+
caveat: 'REST publicMethods do not make GraphQL table data anonymous.',
|
|
1056
1056
|
},
|
|
1057
1057
|
extensionVueSfc: {
|
|
1058
1058
|
runs: 'Frontend extension code, not server sandbox.',
|
|
@@ -1095,7 +1095,7 @@ server.tool(
|
|
|
1095
1095
|
'Auth: publicMethods on a route can allow a method without Bearer; otherwise JWT + routePermissions — see server instructions.',
|
|
1096
1096
|
'If path might differ from table name, use get_all_routes before asserting a URL.',
|
|
1097
1097
|
'Same mapping as MCP tool → HTTP: query_table=GET /table?..., create_record=POST /table, update_record=PATCH /table/id, delete_record=DELETE /table/id.',
|
|
1098
|
-
'GraphQL: see graphqlHttpUrl / graphqlSchemaUrl in response; enable per table via enfyra_graphql/update_table graphqlEnabled and send Bearer auth.',
|
|
1098
|
+
'GraphQL: see graphqlHttpUrl / graphqlSchemaUrl in response; enable per table via enfyra_graphql/update_table graphqlEnabled and send Bearer auth for table data queries. Anonymous root/schema probes may still return 200.',
|
|
1099
1099
|
].join(' '),
|
|
1100
1100
|
{},
|
|
1101
1101
|
async () => {
|
|
@@ -1113,7 +1113,7 @@ server.tool(
|
|
|
1113
1113
|
},
|
|
1114
1114
|
auth: {
|
|
1115
1115
|
publicMethods: 'If the HTTP method is public for that route, no Bearer required; else Bearer JWT and routePermissions apply.',
|
|
1116
|
-
graphql: 'GraphQL
|
|
1116
|
+
graphql: 'GraphQL table data requires Bearer auth; route publicMethods do not make GraphQL table data anonymous. Anonymous root/schema probes may still return 200.',
|
|
1117
1117
|
mcp: 'This server uses admin credentials from env for tools (fetchAPI).',
|
|
1118
1118
|
},
|
|
1119
1119
|
pathResolution: 'Confirm route path with get_all_routes or metadata — path may not equal table name.',
|
|
@@ -2468,137 +2468,6 @@ server.tool(
|
|
|
2468
2468
|
},
|
|
2469
2469
|
);
|
|
2470
2470
|
|
|
2471
|
-
server.tool(
|
|
2472
|
-
'create_column_rule',
|
|
2473
|
-
[
|
|
2474
|
-
'Create a REST body validation rule for a table column.',
|
|
2475
|
-
'Use inspect_table first to confirm validateBody, column type, and existing rules. Rule value is JSON; common shape is {"v": ...}.',
|
|
2476
|
-
].join(' '),
|
|
2477
|
-
{
|
|
2478
|
-
tableName: z.string().describe('Table name or alias'),
|
|
2479
|
-
columnName: z.string().describe('Column name'),
|
|
2480
|
-
ruleType: z.enum(['min', 'max', 'minLength', 'maxLength', 'pattern', 'format', 'minItems', 'maxItems', 'custom']).describe('Validation rule type'),
|
|
2481
|
-
value: z.string().optional().describe('Rule payload JSON, e.g. {"v":10} or {"v":"email"}'),
|
|
2482
|
-
message: z.string().optional().describe('Custom validation error message'),
|
|
2483
|
-
description: z.string().optional().describe('Admin note'),
|
|
2484
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable the rule immediately'),
|
|
2485
|
-
},
|
|
2486
|
-
async ({ tableName, columnName, ruleType, value, message, description, isEnabled }) => {
|
|
2487
|
-
const { tables } = await getMetadataTables();
|
|
2488
|
-
const table = resolveTableOrThrow(tables, tableName);
|
|
2489
|
-
const column = resolveFieldOrThrow(table, columnName, 'column');
|
|
2490
|
-
const body = {
|
|
2491
|
-
ruleType,
|
|
2492
|
-
value: parseJsonArg(value, null),
|
|
2493
|
-
message,
|
|
2494
|
-
description,
|
|
2495
|
-
isEnabled,
|
|
2496
|
-
column: { id: getId(column) },
|
|
2497
|
-
};
|
|
2498
|
-
const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_column_rule', {
|
|
2499
|
-
method: 'POST',
|
|
2500
|
-
body: JSON.stringify(body),
|
|
2501
|
-
});
|
|
2502
|
-
return { content: [{ type: 'text', text: `Column rule created for ${table.name}.${column.name}.\n${JSON.stringify(result, null, 2)}` }] };
|
|
2503
|
-
},
|
|
2504
|
-
);
|
|
2505
|
-
|
|
2506
|
-
server.tool(
|
|
2507
|
-
'create_field_permission',
|
|
2508
|
-
[
|
|
2509
|
-
'Create a field permission for one column or relation.',
|
|
2510
|
-
'Exactly one of columnName or relationName is required. Scope requires roleId or allowedUserIds. Conditions use the field permission condition DSL, not the full query DSL.',
|
|
2511
|
-
].join(' '),
|
|
2512
|
-
{
|
|
2513
|
-
tableName: z.string().describe('Table name or alias'),
|
|
2514
|
-
columnName: z.string().optional().describe('Column name to protect'),
|
|
2515
|
-
relationName: z.string().optional().describe('Relation propertyName to protect'),
|
|
2516
|
-
action: z.enum(['read', 'create', 'update']).default('read').describe('Action this permission applies to'),
|
|
2517
|
-
effect: z.enum(['allow', 'deny']).default('allow').describe('Allow or deny this field action'),
|
|
2518
|
-
roleId: z.union([z.string(), z.number()]).optional().describe('Role id scope'),
|
|
2519
|
-
allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Specific user ids scope'),
|
|
2520
|
-
condition: z.string().optional().describe('Optional condition JSON using field permission condition DSL'),
|
|
2521
|
-
description: z.string().optional().describe('Admin note'),
|
|
2522
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable immediately'),
|
|
2523
|
-
},
|
|
2524
|
-
async ({ tableName, columnName, relationName, action, effect, roleId, allowedUserIds, condition, description, isEnabled }) => {
|
|
2525
|
-
if (!!columnName === !!relationName) throw new Error('Provide exactly one of columnName or relationName');
|
|
2526
|
-
if (!roleId && (!allowedUserIds || allowedUserIds.length === 0)) {
|
|
2527
|
-
throw new Error('Provide roleId or allowedUserIds');
|
|
2528
|
-
}
|
|
2529
|
-
const { tables } = await getMetadataTables();
|
|
2530
|
-
const table = resolveTableOrThrow(tables, tableName);
|
|
2531
|
-
const body = {
|
|
2532
|
-
isEnabled,
|
|
2533
|
-
description,
|
|
2534
|
-
action,
|
|
2535
|
-
effect,
|
|
2536
|
-
condition: parseJsonArg(condition, null),
|
|
2537
|
-
...(roleId ? { role: { id: roleId } } : {}),
|
|
2538
|
-
...(allowedUserIds?.length ? { allowedUsers: allowedUserIds.map((id) => ({ id })) } : {}),
|
|
2539
|
-
};
|
|
2540
|
-
if (columnName) {
|
|
2541
|
-
body.column = { id: getId(resolveFieldOrThrow(table, columnName, 'column')) };
|
|
2542
|
-
} else {
|
|
2543
|
-
body.relation = { id: getId(resolveFieldOrThrow(table, relationName, 'relation')) };
|
|
2544
|
-
}
|
|
2545
|
-
const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_field_permission', {
|
|
2546
|
-
method: 'POST',
|
|
2547
|
-
body: JSON.stringify(body),
|
|
2548
|
-
});
|
|
2549
|
-
return { content: [{ type: 'text', text: `Field permission created on ${table.name}.${columnName || relationName}.\n${JSON.stringify(result, null, 2)}` }] };
|
|
2550
|
-
},
|
|
2551
|
-
);
|
|
2552
|
-
|
|
2553
|
-
server.tool(
|
|
2554
|
-
'create_route_permission',
|
|
2555
|
-
[
|
|
2556
|
-
'Create route access permission for a route and REST methods.',
|
|
2557
|
-
'Use this when a non-root role/user should access an authenticated route. publicMethods are for public access; route permissions are for authenticated role/user access.',
|
|
2558
|
-
].join(' '),
|
|
2559
|
-
{
|
|
2560
|
-
path: z.string().optional().describe('Route path, e.g. /enfyra_user'),
|
|
2561
|
-
routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
|
|
2562
|
-
methods: z.array(z.string()).describe('REST method names this permission allows. Each value must exist in enfyra_method.name.'),
|
|
2563
|
-
roleId: z.union([z.string(), z.number()]).optional().describe('Role id scope'),
|
|
2564
|
-
allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Specific user ids scope'),
|
|
2565
|
-
description: z.string().optional().describe('Admin note'),
|
|
2566
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable immediately'),
|
|
2567
|
-
},
|
|
2568
|
-
async ({ path, routeId, methods, roleId, allowedUserIds, description, isEnabled }) => {
|
|
2569
|
-
if (!path && !routeId) throw new Error('Provide path or routeId');
|
|
2570
|
-
if (!roleId && (!allowedUserIds || allowedUserIds.length === 0)) {
|
|
2571
|
-
throw new Error('Provide roleId or allowedUserIds');
|
|
2572
|
-
}
|
|
2573
|
-
const routes = await fetchAll('/enfyra_route?limit=1000');
|
|
2574
|
-
const route = routes.find((item) => (
|
|
2575
|
-
routeId ? sameId(getId(item), routeId) : item.path === normalizeRestPath(path)
|
|
2576
|
-
));
|
|
2577
|
-
if (!route) throw new Error(`Route not found: ${routeId || path}`);
|
|
2578
|
-
const methodMap = await getMethodMap();
|
|
2579
|
-
const body = {
|
|
2580
|
-
isEnabled,
|
|
2581
|
-
description,
|
|
2582
|
-
route: { id: getId(route) },
|
|
2583
|
-
methods: resolveMethodIds(methodMap, methods),
|
|
2584
|
-
...(roleId ? { role: { id: roleId } } : {}),
|
|
2585
|
-
...(allowedUserIds?.length ? { allowedUsers: allowedUserIds.map((id) => ({ id })) } : {}),
|
|
2586
|
-
};
|
|
2587
|
-
const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_route_permission', {
|
|
2588
|
-
method: 'POST',
|
|
2589
|
-
body: JSON.stringify(body),
|
|
2590
|
-
});
|
|
2591
|
-
const routeReload = await reloadRoutesResult();
|
|
2592
|
-
return { content: [{ type: 'text', text: JSON.stringify({
|
|
2593
|
-
action: 'created',
|
|
2594
|
-
kind: 'route_permission',
|
|
2595
|
-
route: route.path,
|
|
2596
|
-
routeReload,
|
|
2597
|
-
result,
|
|
2598
|
-
}, null, 2) }] };
|
|
2599
|
-
},
|
|
2600
|
-
);
|
|
2601
|
-
|
|
2602
2471
|
server.tool(
|
|
2603
2472
|
'audit_route_access',
|
|
2604
2473
|
[
|
|
@@ -2772,74 +2641,6 @@ server.tool(
|
|
|
2772
2641
|
},
|
|
2773
2642
|
);
|
|
2774
2643
|
|
|
2775
|
-
server.tool(
|
|
2776
|
-
'create_guard',
|
|
2777
|
-
[
|
|
2778
|
-
'Create a metadata guard with optional rules for REST request gating.',
|
|
2779
|
-
'Root guards attach to one route by path/routeId or globally with isGlobal. pre_auth runs before JWT and only has IP/route context; post_auth runs after auth and can use user id.',
|
|
2780
|
-
'Rule types: rate_limit_by_ip, rate_limit_by_user, rate_limit_by_route, ip_whitelist, ip_blacklist. Rate limits use {"maxRequests":number,"perSeconds":number}; IP lists use {"ips":["127.0.0.1","10.0.0.0/24"]}.',
|
|
2781
|
-
'Do not use rate_limit_by_user or userIds on pre_auth guards. Create risky global/IP whitelist guards disabled first, then inspect and test before enabling.',
|
|
2782
|
-
].join(' '),
|
|
2783
|
-
{
|
|
2784
|
-
name: z.string().describe('Guard name'),
|
|
2785
|
-
position: z.enum(['pre_auth', 'post_auth']).default('pre_auth').describe('Execution position for root guard. pre_auth has only IP/route context; post_auth also has authenticated user id.'),
|
|
2786
|
-
routeId: z.union([z.string(), z.number()]).optional().describe('Optional route id'),
|
|
2787
|
-
path: z.string().optional().describe('Optional route path'),
|
|
2788
|
-
methods: z.array(z.string()).optional().describe('Method names this guard applies to. Empty means all configured behavior for route/global.'),
|
|
2789
|
-
combinator: z.enum(['and', 'or']).default('and').describe('How child guards/rules combine'),
|
|
2790
|
-
priority: z.number().optional().default(0).describe('Lower runs first'),
|
|
2791
|
-
isGlobal: z.boolean().optional().default(false).describe('Apply globally instead of one route'),
|
|
2792
|
-
isEnabled: z.boolean().optional().default(false).describe('Enable immediately. Default false to avoid accidental lockout.'),
|
|
2793
|
-
description: z.string().optional().describe('Admin note'),
|
|
2794
|
-
rules: z.string().optional().describe('Optional rules JSON array: [{type, config, priority?, isEnabled?, description?, userIds?}]. Supported types: rate_limit_by_ip, rate_limit_by_user, rate_limit_by_route, ip_whitelist, ip_blacklist.'),
|
|
2795
|
-
},
|
|
2796
|
-
async ({ name, position, routeId, path, methods, combinator, priority, isGlobal, isEnabled, description, rules }) => {
|
|
2797
|
-
let route = null;
|
|
2798
|
-
if (!isGlobal && (routeId || path)) {
|
|
2799
|
-
const routes = await fetchAll('/enfyra_route?limit=1000');
|
|
2800
|
-
route = routes.find((item) => (
|
|
2801
|
-
routeId ? sameId(getId(item), routeId) : item.path === normalizeRestPath(path)
|
|
2802
|
-
));
|
|
2803
|
-
if (!route) throw new Error(`Route not found: ${routeId || path}`);
|
|
2804
|
-
}
|
|
2805
|
-
const methodMap = await getMethodMap();
|
|
2806
|
-
const guardBody = {
|
|
2807
|
-
name,
|
|
2808
|
-
position,
|
|
2809
|
-
combinator,
|
|
2810
|
-
priority,
|
|
2811
|
-
isGlobal,
|
|
2812
|
-
isEnabled,
|
|
2813
|
-
description,
|
|
2814
|
-
...(route ? { route: { id: getId(route) } } : {}),
|
|
2815
|
-
...(methods?.length ? { methods: resolveMethodIds(methodMap, methods) } : {}),
|
|
2816
|
-
};
|
|
2817
|
-
const guard = await fetchAPI(ENFYRA_API_URL, '/enfyra_guard', {
|
|
2818
|
-
method: 'POST',
|
|
2819
|
-
body: JSON.stringify(guardBody),
|
|
2820
|
-
});
|
|
2821
|
-
const ruleInputs = parseJsonArg(rules, []);
|
|
2822
|
-
const createdRules = [];
|
|
2823
|
-
for (const rule of ruleInputs) {
|
|
2824
|
-
const ruleBody = {
|
|
2825
|
-
type: rule.type,
|
|
2826
|
-
config: rule.config,
|
|
2827
|
-
priority: rule.priority ?? 0,
|
|
2828
|
-
isEnabled: rule.isEnabled ?? true,
|
|
2829
|
-
description: rule.description,
|
|
2830
|
-
guard: { id: resultRecordId(guard) },
|
|
2831
|
-
...(Array.isArray(rule.userIds) && rule.userIds.length ? { users: rule.userIds.map((id) => ({ id })) } : {}),
|
|
2832
|
-
};
|
|
2833
|
-
createdRules.push(await fetchAPI(ENFYRA_API_URL, '/enfyra_guard_rule', {
|
|
2834
|
-
method: 'POST',
|
|
2835
|
-
body: JSON.stringify(ruleBody),
|
|
2836
|
-
}));
|
|
2837
|
-
}
|
|
2838
|
-
await fetchAPI(ENFYRA_API_URL, '/admin/reload/guards', { method: 'POST' }).catch(() => {});
|
|
2839
|
-
return { content: [{ type: 'text', text: `Guard created. Guard cache reloaded.\n${JSON.stringify({ guard, rules: createdRules }, null, 2)}` }] };
|
|
2840
|
-
},
|
|
2841
|
-
);
|
|
2842
|
-
|
|
2843
2644
|
// Register table tools
|
|
2844
2645
|
registerTableTools(server, ENFYRA_API_URL);
|
|
2845
2646
|
registerPlatformOperationTools(server, ENFYRA_API_URL);
|