@enfyra/mcp-server 0.1.21 → 0.1.23
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/dist/lib/fetch.d.ts +1 -1
- package/dist/lib/fetch.js +1 -1
- package/dist/lib/fetch.js.map +1 -1
- package/dist/lib/mcp-examples.js +10 -7
- package/dist/lib/mcp-examples.js.map +1 -1
- package/dist/lib/mcp-instructions.d.ts +0 -5
- package/dist/lib/mcp-instructions.js +13 -15
- package/dist/lib/mcp-instructions.js.map +1 -1
- package/dist/lib/required-knowledge.js +3 -0
- package/dist/lib/required-knowledge.js.map +1 -1
- package/dist/lib/table-tools.js +211 -24
- package/dist/lib/table-tools.js.map +1 -1
- package/dist/lib/tool-routing.js +14 -2
- package/dist/lib/tool-routing.js.map +1 -1
- package/dist/mcp-server-entry.js +32 -17
- package/dist/mcp-server-entry.js.map +1 -1
- package/package.json +1 -1
package/dist/lib/table-tools.js
CHANGED
|
@@ -6,7 +6,7 @@ import { fetchAPI } from './fetch.js';
|
|
|
6
6
|
import { jsonContent } from './response-format.js';
|
|
7
7
|
import { assertGlobalRulesAck, globalRulesAckParam } from './required-knowledge.js';
|
|
8
8
|
function bulkObjectArrayParam(z, label) {
|
|
9
|
-
return z.union([z.array(z.record(z.any())), z.string()]).describe(`${label} as a native JSON array of objects. JSON
|
|
9
|
+
return z.union([z.array(z.record(z.any())), z.string()]).describe(`${label} as a native JSON array of objects. Do not JSON.stringify unless your MCP client cannot send arrays.`);
|
|
10
10
|
}
|
|
11
11
|
let schemaQueue = Promise.resolve();
|
|
12
12
|
function withSchemaQueue(operation) {
|
|
@@ -44,6 +44,7 @@ const FALLBACK_COLUMN_TYPES = [
|
|
|
44
44
|
'richtext',
|
|
45
45
|
'float',
|
|
46
46
|
];
|
|
47
|
+
const AUTO_MANAGED_COLUMN_NAMES = new Set(['id', '_id', 'createdAt', 'updatedAt']);
|
|
47
48
|
const COLUMN_TYPE_ALIAS_HINTS = [
|
|
48
49
|
'Use varchar for short strings; text or richtext for long prose.',
|
|
49
50
|
'Use float for prices, money, percentages, ratings, and decimal-like numbers unless the live instance explicitly lists decimal.',
|
|
@@ -131,6 +132,9 @@ function parseJsonArrayParam(name, value) {
|
|
|
131
132
|
return parsed;
|
|
132
133
|
}
|
|
133
134
|
function parseBulkItemsParam(name, value) {
|
|
135
|
+
if (value === undefined || value === null || value === '') {
|
|
136
|
+
throw new Error(`${name} must be a native JSON array. Pass one object in the array for a single mutation.`);
|
|
137
|
+
}
|
|
134
138
|
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
|
135
139
|
if (!Array.isArray(parsed)) {
|
|
136
140
|
throw new Error(`${name} must be a JSON array. Pass one object in the array for a single mutation.`);
|
|
@@ -171,6 +175,97 @@ function normalizeConstraintGroupsValue(name, value) {
|
|
|
171
175
|
}
|
|
172
176
|
return normalizeConstraintGroups(name, parsed);
|
|
173
177
|
}
|
|
178
|
+
function stripAutoManagedColumns(columns) {
|
|
179
|
+
const skippedAutoColumns = [];
|
|
180
|
+
const filtered = columns.filter((column) => {
|
|
181
|
+
const name = String(column?.name ?? '');
|
|
182
|
+
if (!AUTO_MANAGED_COLUMN_NAMES.has(name))
|
|
183
|
+
return true;
|
|
184
|
+
skippedAutoColumns.push({
|
|
185
|
+
name,
|
|
186
|
+
reason: 'Enfyra manages id/createdAt/updatedAt automatically during table creation.',
|
|
187
|
+
});
|
|
188
|
+
return false;
|
|
189
|
+
});
|
|
190
|
+
return { columns: filtered, skippedAutoColumns };
|
|
191
|
+
}
|
|
192
|
+
function assertColumnNameCanBeCreated(name, context) {
|
|
193
|
+
const columnName = String(name ?? '');
|
|
194
|
+
if (AUTO_MANAGED_COLUMN_NAMES.has(columnName)) {
|
|
195
|
+
throw new Error(`${context} "${columnName}" is auto-managed by Enfyra. Do not create id, _id, createdAt, or updatedAt columns manually.`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function assertNoDuplicateFieldNames(fieldNames, context) {
|
|
199
|
+
const seen = new Set();
|
|
200
|
+
const duplicates = new Set();
|
|
201
|
+
for (const name of fieldNames.filter(Boolean)) {
|
|
202
|
+
if (seen.has(name))
|
|
203
|
+
duplicates.add(name);
|
|
204
|
+
seen.add(name);
|
|
205
|
+
}
|
|
206
|
+
if (duplicates.size > 0) {
|
|
207
|
+
throw new Error(`${context} has duplicate field name(s): ${[...duplicates].join(', ')}. Column names and relation propertyName values share one namespace.`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function preflightCreateTableDefinitions(items) {
|
|
211
|
+
items.forEach((item, index) => {
|
|
212
|
+
const columns = Array.isArray(item.columns) ? item.columns : parseJsonArrayParam(`items[${index}].columns`, item.columns || '[]');
|
|
213
|
+
const relations = Array.isArray(item.relations) ? item.relations : parseJsonArrayParam(`items[${index}].relations`, item.relations || '[]');
|
|
214
|
+
const { columns: userColumns } = stripAutoManagedColumns(columns);
|
|
215
|
+
const columnNames = userColumns.map((column) => String(column?.name ?? '')).filter(Boolean);
|
|
216
|
+
const relationNames = relations.map((relation) => String(relation?.propertyName ?? '')).filter(Boolean);
|
|
217
|
+
assertNoDuplicateFieldNames([...columnNames, ...relationNames], `create_tables items[${index}] (${item.name || '<unnamed>'})`);
|
|
218
|
+
const logicalFields = new Set([...AUTO_MANAGED_COLUMN_NAMES, ...columnNames, ...relationNames]);
|
|
219
|
+
const indexes = normalizeConstraintGroupsValue(`items[${index}].indexes`, item.indexes ?? []);
|
|
220
|
+
const uniques = normalizeConstraintGroupsValue(`items[${index}].uniques`, item.uniques ?? []);
|
|
221
|
+
const unknownConstraintFields = [...indexes, ...uniques]
|
|
222
|
+
.flat()
|
|
223
|
+
.filter((field) => !logicalFields.has(field));
|
|
224
|
+
if (unknownConstraintFields.length > 0) {
|
|
225
|
+
throw new Error(`create_tables items[${index}] (${item.name || '<unnamed>'}) has indexes/uniques referencing undeclared field(s): ${[...new Set(unknownConstraintFields)].join(', ')}. ` +
|
|
226
|
+
'Declare each field as a column or relation propertyName in the same table item. For relation-based unique pairs added after create, first create the relations, then call update_tables with the unique group.');
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
function splitRelationConstraintGroups(groups, relationNames) {
|
|
231
|
+
const immediate = [];
|
|
232
|
+
const deferred = [];
|
|
233
|
+
for (const group of groups) {
|
|
234
|
+
if (group.some((field) => relationNames.has(field)))
|
|
235
|
+
deferred.push(group);
|
|
236
|
+
else
|
|
237
|
+
immediate.push(group);
|
|
238
|
+
}
|
|
239
|
+
return { immediate, deferred };
|
|
240
|
+
}
|
|
241
|
+
function mergeConstraintGroups(existing, additions) {
|
|
242
|
+
const seen = new Set(existing.map((group) => JSON.stringify(group)));
|
|
243
|
+
const merged = [...existing];
|
|
244
|
+
for (const group of additions) {
|
|
245
|
+
const key = JSON.stringify(group);
|
|
246
|
+
if (!seen.has(key)) {
|
|
247
|
+
seen.add(key);
|
|
248
|
+
merged.push(group);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return merged;
|
|
252
|
+
}
|
|
253
|
+
function resolveRelationConstraintGroups(table, groups, groupName) {
|
|
254
|
+
const relationByProperty = new Map((table.relations || [])
|
|
255
|
+
.filter((relation) => relation?.propertyName)
|
|
256
|
+
.map((relation) => [relation.propertyName, relation]));
|
|
257
|
+
return groups.map((group) => group.map((field) => {
|
|
258
|
+
const relation = relationByProperty.get(field);
|
|
259
|
+
if (!relation)
|
|
260
|
+
return field;
|
|
261
|
+
const physicalColumn = relation.foreignKeyColumn || relation.fkColumn || relation.fkCol;
|
|
262
|
+
if (!physicalColumn) {
|
|
263
|
+
throw new Error(`${groupName} uses relation propertyName "${field}", but the created relation did not expose a physical FK column for indexing. ` +
|
|
264
|
+
'This usually means the relation is not a direct many-to-one/one-to-one relation. Keep relation indexes/uniques only on direct owning relations.');
|
|
265
|
+
}
|
|
266
|
+
return physicalColumn;
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
174
269
|
export function assertIndexesDoNotReferenceUniqueFields(indexes, uniques) {
|
|
175
270
|
const uniqueFields = new Set(uniques.flat());
|
|
176
271
|
const conflicts = indexes
|
|
@@ -261,6 +356,11 @@ function parseColumnTypeOptions(options) {
|
|
|
261
356
|
.map((item) => item.trim().replace(/^"|"$/g, ''))
|
|
262
357
|
.filter(Boolean);
|
|
263
358
|
}
|
|
359
|
+
function normalizeColumnOptionsValue(options) {
|
|
360
|
+
if (options === undefined)
|
|
361
|
+
return undefined;
|
|
362
|
+
return typeof options === 'string' ? JSON.parse(options) : options;
|
|
363
|
+
}
|
|
264
364
|
export function getSupportedColumnTypesFromMetadata(metadata) {
|
|
265
365
|
const columnTable = normalizeTablesFromMetadata(metadata).find((table) => table?.name === 'enfyra_column');
|
|
266
366
|
const typeColumn = columnTable?.columns?.find((column) => column?.name === 'type');
|
|
@@ -410,7 +510,7 @@ export function buildColumnDefinition({ name, type, supportedTypes, isNullable,
|
|
|
410
510
|
if (description !== undefined)
|
|
411
511
|
column.description = description;
|
|
412
512
|
if (options !== undefined)
|
|
413
|
-
column.options =
|
|
513
|
+
column.options = normalizeColumnOptionsValue(options);
|
|
414
514
|
return column;
|
|
415
515
|
}
|
|
416
516
|
/**
|
|
@@ -430,6 +530,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
430
530
|
}
|
|
431
531
|
const supportedTypes = getSupportedColumnTypesFromMetadata(metadata);
|
|
432
532
|
const normalized = normalizeColumnTypeForLiveMetadata(args.type, supportedTypes);
|
|
533
|
+
assertColumnNameCanBeCreated(args.name, 'create_columns');
|
|
433
534
|
const existingColumns = getPatchableColumns(tableData.columns);
|
|
434
535
|
const beforeIds = existingColumns.map((column) => String(getId(column)));
|
|
435
536
|
const newCol = buildColumnDefinition({ ...args, supportedTypes });
|
|
@@ -452,10 +553,14 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
452
553
|
assertGlobalRulesAck(args.globalRulesAckKey);
|
|
453
554
|
return withSchemaQueue(async () => {
|
|
454
555
|
assertNoForbiddenRelationKeys(args);
|
|
455
|
-
const { sourceTableId, targetTableId, type, propertyName, inversePropertyName, mappedBy, isNullable, onDelete, description } = args;
|
|
556
|
+
const { sourceTableId, targetTableId, targetTable, type, propertyName, inversePropertyName, mappedBy, isNullable, onDelete, description } = args;
|
|
557
|
+
const targetRef = targetTableId ?? targetTable;
|
|
558
|
+
if (targetRef === undefined || targetRef === null || targetRef === '') {
|
|
559
|
+
throw new Error('create_relations requires targetTableId or targetTable. Pass an existing table id, name, or alias.');
|
|
560
|
+
}
|
|
456
561
|
const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
|
|
457
562
|
const resolvedSourceTableId = resolveTableIdentifierFromMetadata(metadata, sourceTableId, 'sourceTableId');
|
|
458
|
-
const resolvedTargetTableId = resolveTableIdentifierFromMetadata(metadata,
|
|
563
|
+
const resolvedTargetTableId = resolveTableIdentifierFromMetadata(metadata, targetRef, 'targetTableId');
|
|
459
564
|
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, resolvedSourceTableId);
|
|
460
565
|
if (!tableData) {
|
|
461
566
|
throw new Error(`Table ${sourceTableId} not found.`);
|
|
@@ -569,7 +674,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
569
674
|
}
|
|
570
675
|
const columnCreateSchema = {
|
|
571
676
|
tableId: z.string().describe('Table definition ID (from get_all_tables or create_tables).'),
|
|
572
|
-
name: z.string().describe('Column name (e.g., "title", "webhook_secret"). Lowercase with underscores.'),
|
|
677
|
+
name: z.string().describe('Column name (e.g., "title", "webhook_secret"). Lowercase with underscores. Do not create id, _id, createdAt, or updatedAt; Enfyra manages them automatically.'),
|
|
573
678
|
type: z.string().describe('Column type from the live enfyra_column.type enum. Common valid types are int, varchar, text, boolean, uuid, ObjectId, bigint, date, datetime, timestamp, enum, simple-json, code, array-select, richtext, and float. The tool normalizes common aliases before sending: decimal/numeric/money/number -> float when decimal is not live-supported, longtext -> text, json/jsonb/object/array -> simple-json when live-supported, string -> varchar. Prefer relations instead of *_id columns.'),
|
|
574
679
|
isNullable: z.boolean().optional().default(true).describe('Set to false if column cannot be null.'),
|
|
575
680
|
isUnique: z.boolean().optional().default(false).describe('Set to true for unique constraint.'),
|
|
@@ -581,12 +686,13 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
581
686
|
isSystem: z.boolean().optional().describe('Set true only for system-managed columns. Avoid for normal app fields.'),
|
|
582
687
|
defaultValue: z.string().optional().describe('Default value as JSON string or backend-supported literal.'),
|
|
583
688
|
description: z.string().optional().describe('Column description.'),
|
|
584
|
-
options: z.string().optional().describe('Column options as JSON string
|
|
689
|
+
options: z.union([z.array(z.string()), z.string()]).optional().describe('Column options as a native array such as ["draft","published"] or a JSON string for older clients.'),
|
|
585
690
|
globalRulesAckKey: globalRulesAckParam(z),
|
|
586
691
|
};
|
|
587
692
|
const relationCreateSchema = {
|
|
588
693
|
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.'),
|
|
589
|
-
targetTableId: z.string().describe('Target table id, exact table name, or alias. MCP resolves names/aliases to ids before mutation.'),
|
|
694
|
+
targetTableId: z.string().optional().describe('Target table id, exact table name, or alias. MCP resolves names/aliases to ids before mutation. Optional when targetTable is provided.'),
|
|
695
|
+
targetTable: z.string().optional().describe('Alias for targetTableId when naturally using a target table name/alias such as enfyra_user. Optional when targetTableId is provided.'),
|
|
590
696
|
type: z.enum(['many-to-one', 'one-to-many', 'one-to-one', 'many-to-many']).describe('Relation type.'),
|
|
591
697
|
propertyName: z.string().describe('Property name on source table (e.g., "customer", "items").'),
|
|
592
698
|
inversePropertyName: z.string().optional().describe('Property name on target table for bidirectional relation (e.g., "orders"). Omit unless a concrete response, UI, deep query, aggregate sort/count, or parent-to-child traversal will use the reverse field. Do not add inverses merely because a parent table exists.'),
|
|
@@ -625,17 +731,26 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
625
731
|
const userColumns = arrayValue('columns', args.columns);
|
|
626
732
|
const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
|
|
627
733
|
const supportedTypes = getSupportedColumnTypesFromMetadata(metadata);
|
|
628
|
-
const { columns:
|
|
734
|
+
const { columns: userColumnsWithoutAuto, skippedAutoColumns } = stripAutoManagedColumns(userColumns);
|
|
735
|
+
const { columns: normalizedUserColumns, normalizations } = normalizeColumnsForLiveMetadata(userColumnsWithoutAuto, supportedTypes);
|
|
736
|
+
const deferredRelations = arrayValue('relations', args.relations).map(normalizeRelationForTablePatch);
|
|
737
|
+
const relationNames = new Set(deferredRelations.map((relation) => relation.propertyName).filter(Boolean));
|
|
738
|
+
assertNoDuplicateFieldNames([
|
|
739
|
+
...normalizedUserColumns.map((column) => String(column.name || '')).filter(Boolean),
|
|
740
|
+
...deferredRelations.map((relation) => String(relation.propertyName || '')).filter(Boolean),
|
|
741
|
+
], `create_tables item "${args.name}"`);
|
|
629
742
|
const indexes = normalizeConstraintGroupsValue('indexes', args.indexes ?? []);
|
|
630
743
|
const uniques = normalizeConstraintGroupsValue('uniques', args.uniques ?? []);
|
|
631
744
|
assertIndexesDoNotReferenceUniqueFields(indexes, uniques);
|
|
745
|
+
const splitIndexes = splitRelationConstraintGroups(indexes, relationNames);
|
|
746
|
+
const splitUniques = splitRelationConstraintGroups(uniques, relationNames);
|
|
632
747
|
const body = { name: args.name, description: args.description, columns: [idColumn, ...normalizedUserColumns], relations: [] };
|
|
633
748
|
if (args.isSingleRecord !== undefined)
|
|
634
749
|
body.isSingleRecord = args.isSingleRecord;
|
|
635
750
|
if (args.indexes !== undefined)
|
|
636
|
-
body.indexes =
|
|
751
|
+
body.indexes = splitIndexes.immediate;
|
|
637
752
|
if (args.uniques !== undefined)
|
|
638
|
-
body.uniques =
|
|
753
|
+
body.uniques = splitUniques.immediate;
|
|
639
754
|
const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_table', {
|
|
640
755
|
method: 'POST',
|
|
641
756
|
body: JSON.stringify(body),
|
|
@@ -648,7 +763,6 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
648
763
|
? null
|
|
649
764
|
: liveMetadataAfterCreate?.data?.table || liveMetadataAfterCreate?.data || liveMetadataAfterCreate?.table || liveMetadataAfterCreate;
|
|
650
765
|
const liveSchema = summarizeCreatedTableSchema(liveTableAfterCreate, args.name);
|
|
651
|
-
const deferredRelations = arrayValue('relations', args.relations).map(normalizeRelationForTablePatch);
|
|
652
766
|
const routePath = `/${args.name}`;
|
|
653
767
|
return {
|
|
654
768
|
action: 'table_created',
|
|
@@ -659,8 +773,10 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
659
773
|
deferredRelationCount: deferredRelations.length,
|
|
660
774
|
indexGroupCount: indexes.length,
|
|
661
775
|
uniqueGroupCount: uniques.length,
|
|
776
|
+
deferredConstraintCount: splitIndexes.deferred.length + splitUniques.deferred.length,
|
|
662
777
|
},
|
|
663
778
|
schemaNormalization: normalizations,
|
|
779
|
+
skippedAutoColumns,
|
|
664
780
|
schema: {
|
|
665
781
|
intended: {
|
|
666
782
|
tableName: args.name,
|
|
@@ -681,6 +797,39 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
681
797
|
noGetById: true,
|
|
682
798
|
},
|
|
683
799
|
deferredRelations,
|
|
800
|
+
deferredConstraints: {
|
|
801
|
+
indexes: splitIndexes.deferred,
|
|
802
|
+
uniques: splitUniques.deferred,
|
|
803
|
+
},
|
|
804
|
+
result,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
async function applyDeferredConstraints(tableId, deferredConstraints) {
|
|
808
|
+
const deferredIndexes = deferredConstraints?.indexes || [];
|
|
809
|
+
const deferredUniques = deferredConstraints?.uniques || [];
|
|
810
|
+
if (deferredIndexes.length === 0 && deferredUniques.length === 0)
|
|
811
|
+
return null;
|
|
812
|
+
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
|
|
813
|
+
const mappedIndexes = resolveRelationConstraintGroups(tableData, deferredIndexes, 'indexes');
|
|
814
|
+
const mappedUniques = resolveRelationConstraintGroups(tableData, deferredUniques, 'uniques');
|
|
815
|
+
const existingIndexes = normalizeConstraintGroupsValue('indexes', tableData.indexes || []);
|
|
816
|
+
const existingUniques = normalizeConstraintGroupsValue('uniques', tableData.uniques || []);
|
|
817
|
+
const indexes = mergeConstraintGroups(existingIndexes, mappedIndexes);
|
|
818
|
+
const uniques = mergeConstraintGroups(existingUniques, mappedUniques);
|
|
819
|
+
assertIndexesDoNotReferenceUniqueFields(indexes, uniques);
|
|
820
|
+
const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, { indexes, uniques });
|
|
821
|
+
return {
|
|
822
|
+
action: 'deferred_constraints_applied',
|
|
823
|
+
tableId,
|
|
824
|
+
tableName: tableData.name,
|
|
825
|
+
requested: {
|
|
826
|
+
indexes: deferredIndexes,
|
|
827
|
+
uniques: deferredUniques,
|
|
828
|
+
},
|
|
829
|
+
applied: {
|
|
830
|
+
indexes: mappedIndexes,
|
|
831
|
+
uniques: mappedUniques,
|
|
832
|
+
},
|
|
684
833
|
result,
|
|
685
834
|
};
|
|
686
835
|
}
|
|
@@ -768,7 +917,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
768
917
|
if (description !== undefined)
|
|
769
918
|
rest.description = description;
|
|
770
919
|
if (options !== undefined)
|
|
771
|
-
rest.options =
|
|
920
|
+
rest.options = normalizeColumnOptionsValue(options);
|
|
772
921
|
}
|
|
773
922
|
return rest;
|
|
774
923
|
});
|
|
@@ -856,7 +1005,9 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
856
1005
|
cascadeFields: ['columns', 'relations'],
|
|
857
1006
|
constraintFields: ['indexes', 'uniques'],
|
|
858
1007
|
notAcceptedAtCreate: ['alias', 'graphqlEnabled'],
|
|
1008
|
+
autoManagedColumns: ['id', 'createdAt', 'updatedAt'],
|
|
859
1009
|
idColumn: 'create_tables auto-includes an int id primary key; do not include your own id unless the user asks for custom primary-key behavior.',
|
|
1010
|
+
reservedColumnRule: 'Do not declare id, _id, createdAt, or updatedAt in create_tables/create_columns. create_tables strips them before save and reports skippedAutoColumns; create_columns rejects them.',
|
|
860
1011
|
},
|
|
861
1012
|
columnDefinitionInput: {
|
|
862
1013
|
allowedFields: ['name', 'type', 'isNullable', 'isUnique', 'isPublished', 'isUpdatable', 'isEncrypted', 'defaultValue', 'description', 'options'],
|
|
@@ -876,7 +1027,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
876
1027
|
aliasNormalization: 'Schema tools normalize common aliases where possible and return schemaNormalization, but models should choose from liveTypes directly.',
|
|
877
1028
|
},
|
|
878
1029
|
relationDefinitionInput: {
|
|
879
|
-
allowedFields: ['targetTable', 'type', 'propertyName', 'inversePropertyName', 'mappedBy', 'isNullable', 'onDelete', 'description'],
|
|
1030
|
+
allowedFields: ['targetTableId', 'targetTable', 'type', 'propertyName', 'inversePropertyName', 'mappedBy', 'isNullable', 'onDelete', 'description'],
|
|
880
1031
|
relationTypes: relationTypes.length ? relationTypes : ['many-to-one', 'one-to-many', 'one-to-one', 'many-to-many'],
|
|
881
1032
|
onDeleteOptions: onDeleteOptions.length ? onDeleteOptions : ['CASCADE', 'SET NULL', 'RESTRICT'],
|
|
882
1033
|
forbiddenPhysicalFields: FORBIDDEN_RELATION_KEYS,
|
|
@@ -886,14 +1037,17 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
886
1037
|
indexes: 'JSON array of non-unique logical field groups, e.g. [["status","createdAt"]]. Relation propertyName values are allowed.',
|
|
887
1038
|
uniques: 'JSON array of unique logical field groups, e.g. [["record","actor"]].',
|
|
888
1039
|
uniqueIndexRule: 'Any field in uniques must not appear in indexes because unique constraints already create indexed lookups.',
|
|
1040
|
+
createTablesPreflight: 'create_tables rejects constraints that reference fields not declared as scalar columns, auto-managed columns, or relation propertyName values in that same table item before it creates anything.',
|
|
1041
|
+
relationBasedUniques: 'For one-pass schema creation, put the owning relations in the same create_tables item as the relation-based unique group. If relations already exist, add relation-based uniques later with update_tables.',
|
|
889
1042
|
},
|
|
890
1043
|
recommendedSequence: [
|
|
891
1044
|
'1. Name domain entities and decide which existing tables are reused, especially enfyra_user for users/owners/actors.',
|
|
892
1045
|
'2. Create independent lookup/base tables first with scalar columns only.',
|
|
893
1046
|
'3. Create dependent tables with scalar columns and relations whose target tables already exist.',
|
|
894
1047
|
'4. Use create_relations after both tables exist when a relation could not be included during table creation.',
|
|
895
|
-
'5.
|
|
896
|
-
'6.
|
|
1048
|
+
'5. Add relation-based unique groups in the same create_tables item when the relations are declared there, or via update_tables after relations exist.',
|
|
1049
|
+
'6. Insert records using column names and relation propertyName values, never hidden FK columns.',
|
|
1050
|
+
'7. Re-inspect each table with inspect_table before writing records or adding query examples.',
|
|
897
1051
|
],
|
|
898
1052
|
liveMetadataAttributes: {
|
|
899
1053
|
enfyra_table: tableAttributes,
|
|
@@ -907,23 +1061,37 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
907
1061
|
'Create one or more table definitions. Always pass items as a native JSON array; for one table, pass one item. JSON string arrays are accepted only for older MCP clients.',
|
|
908
1062
|
'The tool creates tables sequentially, creates columns with each table, then creates all requested relations after every table in the batch exists. This avoids relation target races for weak agents.',
|
|
909
1063
|
'Each item supports { name, description?, isSingleRecord?, columns?, relations?, indexes?, uniques? }. columns/relations/indexes/uniques may be arrays inside the item.',
|
|
1064
|
+
'Do not include id, _id, createdAt, or updatedAt in columns; Enfyra manages them and create_tables strips them before save.',
|
|
1065
|
+
'Every field named in indexes/uniques must be a scalar column, auto-managed column, or relation propertyName in the same table item; otherwise the tool rejects the whole batch before creating tables.',
|
|
910
1066
|
'Use get_schema_design_context first for live column types and relation rules. Do not include physical FK fields.',
|
|
911
1067
|
].join(' '), {
|
|
912
|
-
items: bulkObjectArrayParam(z, 'Table definitions').describe('Native JSON array of table definitions. Pass one object in the array for a single table.'),
|
|
1068
|
+
items: bulkObjectArrayParam(z, 'Table definitions').optional().describe('Native JSON array of table definitions. Pass one object in the array for a single table.'),
|
|
1069
|
+
tables: bulkObjectArrayParam(z, 'Table definitions').optional().describe('Alias for items when the caller naturally names the batch tables. Pass either items or tables, not both.'),
|
|
913
1070
|
maxItems: z.number().int().min(1).max(100).optional().default(100).describe('Safety cap for one schema batch. Default/max is 100; operations still run sequentially.'),
|
|
914
1071
|
globalRulesAckKey: globalRulesAckParam(z),
|
|
915
|
-
}, async ({ items, maxItems, globalRulesAckKey }) => {
|
|
1072
|
+
}, async ({ items, tables, maxItems, globalRulesAckKey }) => {
|
|
916
1073
|
assertGlobalRulesAck(globalRulesAckKey);
|
|
917
|
-
|
|
1074
|
+
if (items !== undefined && tables !== undefined)
|
|
1075
|
+
throw new Error('Pass either items or tables to create_tables, not both.');
|
|
1076
|
+
const parsedItems = parseBulkItemsParam('items', items ?? tables);
|
|
918
1077
|
assertBulkLimit('create_tables', parsedItems, maxItems);
|
|
1078
|
+
preflightCreateTableDefinitions(parsedItems);
|
|
919
1079
|
const created = [];
|
|
920
1080
|
const deferredRelations = [];
|
|
1081
|
+
const deferredConstraints = [];
|
|
921
1082
|
for (const [index, item] of parsedItems.entries()) {
|
|
922
1083
|
const result = await withSchemaQueue(() => createOneTable(item));
|
|
923
1084
|
created.push({ index, ...result, deferredRelations: undefined });
|
|
924
1085
|
for (const relation of result.deferredRelations || []) {
|
|
925
1086
|
deferredRelations.push({ index, sourceTableId: result.table.id || result.table.name, ...relation });
|
|
926
1087
|
}
|
|
1088
|
+
if ((result.deferredConstraints?.indexes || []).length || (result.deferredConstraints?.uniques || []).length) {
|
|
1089
|
+
deferredConstraints.push({
|
|
1090
|
+
index,
|
|
1091
|
+
tableId: result.table.id || result.table.name,
|
|
1092
|
+
...result.deferredConstraints,
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
927
1095
|
}
|
|
928
1096
|
const createdRelations = [];
|
|
929
1097
|
for (const relation of deferredRelations) {
|
|
@@ -941,16 +1109,29 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
941
1109
|
});
|
|
942
1110
|
createdRelations.push({ index: relation.index, ...JSON.parse(relationResult.content[0].text) });
|
|
943
1111
|
}
|
|
1112
|
+
const appliedDeferredConstraints = [];
|
|
1113
|
+
for (const constraints of deferredConstraints) {
|
|
1114
|
+
const constraintResult = await withSchemaQueue(() => applyDeferredConstraints(constraints.tableId, {
|
|
1115
|
+
indexes: constraints.indexes,
|
|
1116
|
+
uniques: constraints.uniques,
|
|
1117
|
+
}));
|
|
1118
|
+
if (constraintResult)
|
|
1119
|
+
appliedDeferredConstraints.push({ index: constraints.index, ...constraintResult });
|
|
1120
|
+
}
|
|
944
1121
|
return jsonContent({
|
|
945
1122
|
action: 'tables_created',
|
|
946
1123
|
requested: parsedItems.length,
|
|
947
1124
|
createdCount: created.length,
|
|
948
1125
|
deferredRelationCount: deferredRelations.length,
|
|
949
1126
|
createdRelationCount: createdRelations.length,
|
|
1127
|
+
deferredConstraintCount: deferredConstraints.length,
|
|
1128
|
+
appliedDeferredConstraintCount: appliedDeferredConstraints.length,
|
|
950
1129
|
sequential: true,
|
|
951
1130
|
relationPhaseAfterTables: true,
|
|
1131
|
+
constraintPhaseAfterRelations: true,
|
|
952
1132
|
created,
|
|
953
1133
|
createdRelations,
|
|
1134
|
+
appliedDeferredConstraints,
|
|
954
1135
|
});
|
|
955
1136
|
});
|
|
956
1137
|
server.tool('update_tables', 'Update one or more table definitions. Always pass items as a native JSON array; for one table, pass one item. Items run sequentially through the schema queue. JSON string arrays are accepted only for older MCP clients.', {
|
|
@@ -998,12 +1179,15 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
998
1179
|
});
|
|
999
1180
|
// ─── COLUMN MUTATIONS ───
|
|
1000
1181
|
server.tool('create_columns', 'Create one or more columns. Always pass items as a native JSON array; for one column, pass one item. Items run sequentially through the schema queue. JSON string arrays are accepted only for older MCP clients.', {
|
|
1001
|
-
items: bulkObjectArrayParam(z, 'Column definitions').describe('Native JSON array of column definitions. Each item uses create_columns fields: { tableId, name, type, isNullable?, isUnique?, isPublished?, isUpdatable?, isEncrypted?, isPrimary?, isGenerated?, isSystem?, defaultValue?, description?, options? }.'),
|
|
1182
|
+
items: bulkObjectArrayParam(z, 'Column definitions').optional().describe('Native JSON array of column definitions. Each item uses create_columns fields: { tableId, name, type, isNullable?, isUnique?, isPublished?, isUpdatable?, isEncrypted?, isPrimary?, isGenerated?, isSystem?, defaultValue?, description?, options? }.'),
|
|
1183
|
+
columns: bulkObjectArrayParam(z, 'Column definitions').optional().describe('Alias for items when the caller naturally names the batch columns. Pass either items or columns, not both.'),
|
|
1002
1184
|
maxItems: z.number().int().min(1).max(100).optional().default(100).describe('Safety cap for one schema batch. Default/max is 100.'),
|
|
1003
1185
|
globalRulesAckKey: globalRulesAckParam(z),
|
|
1004
|
-
}, async ({ items, maxItems, globalRulesAckKey }) => {
|
|
1186
|
+
}, async ({ items, columns, maxItems, globalRulesAckKey }) => {
|
|
1005
1187
|
assertGlobalRulesAck(globalRulesAckKey);
|
|
1006
|
-
|
|
1188
|
+
if (items !== undefined && columns !== undefined)
|
|
1189
|
+
throw new Error('Pass either items or columns to create_columns, not both.');
|
|
1190
|
+
const parsedItems = parseBulkItemsParam('items', items ?? columns);
|
|
1007
1191
|
assertBulkLimit('create_columns', parsedItems, maxItems);
|
|
1008
1192
|
const created = [];
|
|
1009
1193
|
for (const [index, item] of parsedItems.entries()) {
|
|
@@ -1061,12 +1245,15 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
1061
1245
|
});
|
|
1062
1246
|
// ─── RELATION MUTATIONS ───
|
|
1063
1247
|
server.tool('create_relations', 'Create one or more relations. Always pass items as a native JSON array; for one relation, pass one item. Items run sequentially through the schema queue and table names/aliases are resolved internally. JSON string arrays are accepted only for older MCP clients.', {
|
|
1064
|
-
items: bulkObjectArrayParam(z, 'Relation definitions').describe('Native JSON array of relation definitions. Each item uses { sourceTableId, targetTableId, type, propertyName, inversePropertyName?, mappedBy?, isNullable?, onDelete?, description? }. Do not send physical FK fields.'),
|
|
1248
|
+
items: bulkObjectArrayParam(z, 'Relation definitions').optional().describe('Native JSON array of relation definitions. Each item uses { sourceTableId, targetTableId or targetTable, type, propertyName, inversePropertyName?, mappedBy?, isNullable?, onDelete?, description? }. Do not send physical FK fields.'),
|
|
1249
|
+
relations: bulkObjectArrayParam(z, 'Relation definitions').optional().describe('Alias for items when the caller naturally names the batch relations. Pass either items or relations, not both.'),
|
|
1065
1250
|
maxItems: z.number().int().min(1).max(100).optional().default(100).describe('Safety cap for one schema batch. Default/max is 100.'),
|
|
1066
1251
|
globalRulesAckKey: globalRulesAckParam(z),
|
|
1067
|
-
}, async ({ items, maxItems, globalRulesAckKey }) => {
|
|
1252
|
+
}, async ({ items, relations, maxItems, globalRulesAckKey }) => {
|
|
1068
1253
|
assertGlobalRulesAck(globalRulesAckKey);
|
|
1069
|
-
|
|
1254
|
+
if (items !== undefined && relations !== undefined)
|
|
1255
|
+
throw new Error('Pass either items or relations to create_relations, not both.');
|
|
1256
|
+
const parsedItems = parseBulkItemsParam('items', items ?? relations);
|
|
1070
1257
|
assertBulkLimit('create_relations', parsedItems, maxItems);
|
|
1071
1258
|
const created = [];
|
|
1072
1259
|
for (const [index, item] of parsedItems.entries()) {
|