@enfyra/mcp-server 0.1.31 → 0.1.33

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.
@@ -44,6 +44,21 @@ const FALLBACK_COLUMN_TYPES = [
44
44
  'richtext',
45
45
  'float',
46
46
  ];
47
+ const RELATION_TYPE_ALIASES = {
48
+ many_to_one: 'many-to-one',
49
+ manyToOne: 'many-to-one',
50
+ manytoone: 'many-to-one',
51
+ one_to_many: 'one-to-many',
52
+ oneToMany: 'one-to-many',
53
+ onetomany: 'one-to-many',
54
+ one_to_one: 'one-to-one',
55
+ oneToOne: 'one-to-one',
56
+ onetoone: 'one-to-one',
57
+ many_to_many: 'many-to-many',
58
+ manyToMany: 'many-to-many',
59
+ manytomany: 'many-to-many',
60
+ };
61
+ const VALID_RELATION_TYPES = new Set(['many-to-one', 'one-to-many', 'one-to-one', 'many-to-many']);
47
62
  const AUTO_MANAGED_COLUMN_NAMES = new Set(['id', '_id', 'createdAt', 'updatedAt']);
48
63
  const COLUMN_TYPE_ALIAS_HINTS = [
49
64
  'Use varchar for short strings; text or richtext for long prose.',
@@ -207,6 +222,34 @@ function assertNoDuplicateFieldNames(fieldNames, context) {
207
222
  throw new Error(`${context} has duplicate field name(s): ${[...duplicates].join(', ')}. Column names and relation propertyName values share one namespace.`);
208
223
  }
209
224
  }
225
+ function assertNoColumnRelationNameCollision(columnNames, relationNames, context) {
226
+ const relationNameSet = new Set(relationNames.filter(Boolean));
227
+ const collisions = columnNames.filter((name) => relationNameSet.has(name));
228
+ if (collisions.length > 0) {
229
+ throw new Error(`${context} has column/relation namespace collision(s): ${[...new Set(collisions)].join(', ')}. ` +
230
+ `Column names and relation propertyName values share one namespace; remove the scalar column(s) and keep the relation propertyName(s) ${[...new Set(collisions)].join(', ')}. Do not create physical FK columns.`);
231
+ }
232
+ assertNoDuplicateFieldNames([...columnNames, ...relationNames], context);
233
+ }
234
+ function formatConstraintFieldHints(fields, relationNames, logicalFieldNames) {
235
+ const relationNameSet = new Set(relationNames);
236
+ const logicalNameSet = new Set(logicalFieldNames);
237
+ const normalizeName = (value) => value.replace(/[_\-\s]/gu, '').toLowerCase();
238
+ return fields
239
+ .map((field) => {
240
+ const normalized = String(field)
241
+ .replace(/_?ids?$/iu, '')
242
+ .replace(/_id$/iu, '')
243
+ .replace(/Id$/u, '')
244
+ .replace(/Ids$/u, '');
245
+ const match = [...relationNameSet].find((name) => name.toLowerCase() === normalized.toLowerCase());
246
+ if (match)
247
+ return `${field} -> use relation propertyName "${match}" in indexes/uniques, not physical FK "${field}"`;
248
+ const logicalMatch = [...logicalNameSet].find((name) => normalizeName(name) === normalizeName(String(field)));
249
+ return logicalMatch ? `${field} -> did you mean "${logicalMatch}"? Constraint fields must match column/relation names exactly.` : null;
250
+ })
251
+ .filter(Boolean);
252
+ }
210
253
  function preflightCreateTableDefinitions(items) {
211
254
  items.forEach((item, index) => {
212
255
  const columns = Array.isArray(item.columns) ? item.columns : parseJsonArrayParam(`items[${index}].columns`, item.columns || '[]');
@@ -214,7 +257,7 @@ function preflightCreateTableDefinitions(items) {
214
257
  const { columns: userColumns } = stripAutoManagedColumns(columns);
215
258
  const columnNames = userColumns.map((column) => String(column?.name ?? '')).filter(Boolean);
216
259
  const relationNames = relations.map((relation) => String(relation?.propertyName ?? '')).filter(Boolean);
217
- assertNoDuplicateFieldNames([...columnNames, ...relationNames], `create_tables items[${index}] (${item.name || '<unnamed>'})`);
260
+ assertNoColumnRelationNameCollision(columnNames, relationNames, `create_tables items[${index}] (${item.name || '<unnamed>'})`);
218
261
  const logicalFields = new Set([...AUTO_MANAGED_COLUMN_NAMES, ...columnNames, ...relationNames]);
219
262
  const indexes = normalizeConstraintGroupsValue(`items[${index}].indexes`, item.indexes ?? []);
220
263
  const uniques = normalizeConstraintGroupsValue(`items[${index}].uniques`, item.uniques ?? []);
@@ -223,11 +266,50 @@ function preflightCreateTableDefinitions(items) {
223
266
  .flat()
224
267
  .filter((field) => !logicalFields.has(field));
225
268
  if (unknownConstraintFields.length > 0) {
226
- throw new Error(`create_tables items[${index}] (${item.name || '<unnamed>'}) has indexes/uniques referencing undeclared field(s): ${[...new Set(unknownConstraintFields)].join(', ')}. ` +
227
- '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.');
269
+ const uniqueUnknownFields = [...new Set(unknownConstraintFields)];
270
+ const hints = formatConstraintFieldHints(uniqueUnknownFields, relationNames, [...logicalFields]);
271
+ throw new Error(`create_tables items[${index}] (${item.name || '<unnamed>'}) has indexes/uniques referencing undeclared field(s): ${uniqueUnknownFields.join(', ')}. ` +
272
+ 'Declare each field as a column or relation propertyName in the same table item. ' +
273
+ (hints.length ? `Hint(s): ${hints.join('; ')}. ` : '') +
274
+ 'For relation-based unique pairs added after create, first create the relations, then call update_tables with the unique group.');
228
275
  }
229
276
  });
230
277
  }
278
+ function relationTargetName(relation) {
279
+ const target = relation?.targetTable ?? relation?.targetTableId;
280
+ if (target && typeof target === 'object')
281
+ return target.name ?? target.alias ?? target.id ?? target._id;
282
+ return target;
283
+ }
284
+ export function computeBatchCleanupOrder(items) {
285
+ const tableNames = items.map((item) => String(item?.name || '')).filter(Boolean);
286
+ const tableSet = new Set(tableNames);
287
+ const edges = [];
288
+ for (const item of items) {
289
+ const source = String(item?.name || '');
290
+ if (!source)
291
+ continue;
292
+ const relations = Array.isArray(item.relations) ? item.relations : parseJsonArrayParam(`${source}.relations`, item.relations || '[]');
293
+ for (const relation of relations) {
294
+ const target = String(relationTargetName(relation) || '');
295
+ if (target && tableSet.has(target) && target !== source)
296
+ edges.push([source, target]);
297
+ }
298
+ }
299
+ const remaining = new Set(tableNames);
300
+ const ordered = [];
301
+ while (remaining.size > 0) {
302
+ const leaves = [...remaining]
303
+ .filter((name) => !edges.some(([source, target]) => target === name && remaining.has(source) && remaining.has(target)))
304
+ .sort();
305
+ const batch = leaves.length ? leaves : [[...remaining].sort()[0]];
306
+ for (const name of batch) {
307
+ remaining.delete(name);
308
+ ordered.push(name);
309
+ }
310
+ }
311
+ return ordered;
312
+ }
231
313
  function splitRelationConstraintGroups(groups, relationNames) {
232
314
  const immediate = [];
233
315
  const deferred = [];
@@ -268,20 +350,38 @@ function resolveRelationConstraintGroups(table, groups, groupName) {
268
350
  }));
269
351
  }
270
352
  export function assertIndexesDoNotReferenceUniqueFields(indexes, uniques) {
271
- const uniqueFields = new Set(uniques.flat());
272
353
  const conflicts = indexes
273
- .map((group) => ({
274
- index: group,
275
- uniqueFields: group.filter((field) => uniqueFields.has(field)),
354
+ .map((indexGroup) => ({
355
+ indexGroup,
356
+ uniqueGroups: uniques
357
+ .map((uniqueGroup) => ({
358
+ uniqueGroup,
359
+ overlappingFields: indexGroup.filter((field) => uniqueGroup.includes(field)),
360
+ }))
361
+ .filter((conflict) => conflict.overlappingFields.length > 0),
276
362
  }))
277
- .filter((conflict) => conflict.uniqueFields.length > 0);
363
+ .filter((conflict) => conflict.uniqueGroups.length > 0);
278
364
  if (conflicts.length > 0) {
279
365
  const groups = conflicts
280
- .map((conflict) => `${JSON.stringify(conflict.index)} uses unique field(s) ${JSON.stringify(conflict.uniqueFields)}`)
366
+ .map((conflict) => `${JSON.stringify(conflict.indexGroup)} overlaps unique group(s) ${conflict.uniqueGroups.map((item) => `${JSON.stringify(item.uniqueGroup)} via ${JSON.stringify(item.overlappingFields)}`).join(', ')}`)
281
367
  .join('; ');
282
368
  throw new Error(`Invalid schema constraints: indexes must not include fields that appear in uniques, including composite unique groups. Conflict(s): ${groups}. Unique constraints already create indexed lookups for their fields; remove those fields from indexes and keep them only in uniques.`);
283
369
  }
284
370
  }
371
+ function pruneIndexesThatOverlapUniques(indexes, uniques) {
372
+ const uniqueFields = new Set(uniques.flat());
373
+ const kept = [];
374
+ const removed = [];
375
+ for (const indexGroup of indexes) {
376
+ if (indexGroup.some((field) => uniqueFields.has(field))) {
377
+ removed.push(indexGroup);
378
+ }
379
+ else {
380
+ kept.push(indexGroup);
381
+ }
382
+ }
383
+ return { indexes: kept, removed };
384
+ }
285
385
  export function normalizeRelationForTablePatch(relation) {
286
386
  for (const key of FORBIDDEN_RELATION_KEYS) {
287
387
  if (Object.prototype.hasOwnProperty.call(relation, key)) {
@@ -290,6 +390,12 @@ export function normalizeRelationForTablePatch(relation) {
290
390
  }
291
391
  const { sourceTable, targetTable, targetTableId, mappedBy, fkCol, fkColumn, foreignKeyColumn, sourceColumn, targetColumn, junctionSourceColumn, junctionTargetColumn, ...rest } = relation;
292
392
  const normalized = { ...rest };
393
+ if (rest.type !== undefined) {
394
+ normalized.type = normalizeRelationType(rest.type);
395
+ }
396
+ if (normalized.type === 'one-to-many' && mappedBy !== undefined && mappedBy !== null && mappedBy !== '') {
397
+ delete normalized.inversePropertyName;
398
+ }
293
399
  const resolvedTargetTable = targetTableId ??
294
400
  (targetTable && typeof targetTable === 'object'
295
401
  ? targetTable.id ?? targetTable._id ?? targetTable
@@ -304,6 +410,14 @@ export function normalizeRelationForTablePatch(relation) {
304
410
  }
305
411
  return normalized;
306
412
  }
413
+ export function normalizeRelationType(type) {
414
+ const raw = String(type ?? '').trim();
415
+ const normalized = RELATION_TYPE_ALIASES[raw] ?? raw;
416
+ if (!VALID_RELATION_TYPES.has(normalized)) {
417
+ throw new Error(`Invalid relation type "${raw || '<missing>'}". Use one of many-to-one, one-to-many, one-to-one, or many-to-many. Common aliases such as many_to_one are normalized by the tool.`);
418
+ }
419
+ return normalized;
420
+ }
307
421
  function assertNoForbiddenRelationKeys(args) {
308
422
  for (const key of FORBIDDEN_RELATION_KEYS) {
309
423
  if (Object.prototype.hasOwnProperty.call(args, key)) {
@@ -554,7 +668,9 @@ export function registerTableTools(server, ENFYRA_API_URL) {
554
668
  assertGlobalRulesAck(args.globalRulesAckKey);
555
669
  return withSchemaQueue(async () => {
556
670
  assertNoForbiddenRelationKeys(args);
557
- const { sourceTableId, targetTableId, targetTable, type, propertyName, inversePropertyName, mappedBy, isNullable, onDelete, description } = args;
671
+ const { sourceTableId, targetTableId, targetTable } = args;
672
+ const relationPatch = normalizeRelationForTablePatch(args);
673
+ const { type, propertyName, inversePropertyName, mappedBy, isNullable, onDelete, description } = relationPatch;
558
674
  const targetRef = targetTableId ?? targetTable;
559
675
  if (targetRef === undefined || targetRef === null || targetRef === '') {
560
676
  throw new Error('create_relations requires targetTableId or targetTable. Pass an existing table id, name, or alias.');
@@ -694,7 +810,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
694
810
  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.'),
695
811
  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.'),
696
812
  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.'),
697
- type: z.enum(['many-to-one', 'one-to-many', 'one-to-one', 'many-to-many']).describe('Relation type.'),
813
+ type: z.string().describe('Relation type. Use many-to-one, one-to-many, one-to-one, or many-to-many. Common aliases such as many_to_one are normalized by the tool.'),
698
814
  propertyName: z.string().describe('Property name on source table (e.g., "customer", "items").'),
699
815
  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.'),
700
816
  mappedBy: z.string().optional().describe('Mapped-by property for inverse relation shapes when required by the backend. Do not use physical FK names.'),
@@ -736,10 +852,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
736
852
  const { columns: normalizedUserColumns, normalizations } = normalizeColumnsForLiveMetadata(userColumnsWithoutAuto, supportedTypes);
737
853
  const deferredRelations = arrayValue('relations', args.relations).map(normalizeRelationForTablePatch);
738
854
  const relationNames = new Set(deferredRelations.map((relation) => relation.propertyName).filter(Boolean));
739
- assertNoDuplicateFieldNames([
740
- ...normalizedUserColumns.map((column) => String(column.name || '')).filter(Boolean),
741
- ...deferredRelations.map((relation) => String(relation.propertyName || '')).filter(Boolean),
742
- ], `create_tables item "${args.name}"`);
855
+ assertNoColumnRelationNameCollision(normalizedUserColumns.map((column) => String(column.name || '')).filter(Boolean), deferredRelations.map((relation) => String(relation.propertyName || '')).filter(Boolean), `create_tables item "${args.name}"`);
743
856
  const indexes = normalizeConstraintGroupsValue('indexes', args.indexes ?? []);
744
857
  const uniques = normalizeConstraintGroupsValue('uniques', args.uniques ?? []);
745
858
  assertIndexesDoNotReferenceUniqueFields(indexes, uniques);
@@ -815,8 +928,9 @@ export function registerTableTools(server, ENFYRA_API_URL) {
815
928
  const mappedUniques = resolveRelationConstraintGroups(tableData, deferredUniques, 'uniques');
816
929
  const existingIndexes = normalizeConstraintGroupsValue('indexes', tableData.indexes || []);
817
930
  const existingUniques = normalizeConstraintGroupsValue('uniques', tableData.uniques || []);
818
- const indexes = mergeConstraintGroups(existingIndexes, mappedIndexes);
819
931
  const uniques = mergeConstraintGroups(existingUniques, mappedUniques);
932
+ const prunedExistingIndexes = pruneIndexesThatOverlapUniques(existingIndexes, uniques);
933
+ const indexes = mergeConstraintGroups(prunedExistingIndexes.indexes, mappedIndexes);
820
934
  assertIndexesDoNotReferenceUniqueFields(indexes, uniques);
821
935
  const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, { indexes, uniques });
822
936
  return {
@@ -831,11 +945,13 @@ export function registerTableTools(server, ENFYRA_API_URL) {
831
945
  indexes: mappedIndexes,
832
946
  uniques: mappedUniques,
833
947
  },
948
+ prunedExistingIndexes: prunedExistingIndexes.removed,
834
949
  result,
835
950
  };
836
951
  }
837
952
  async function updateOneTable(args) {
838
953
  const body = {};
954
+ let schemaConstraintNormalization;
839
955
  if (args.name !== undefined)
840
956
  body.name = args.name;
841
957
  if (args.alias !== undefined)
@@ -860,12 +976,22 @@ export function registerTableTools(server, ENFYRA_API_URL) {
860
976
  if (uniques === undefined)
861
977
  uniques = normalizeConstraintGroupsValue('uniques', existing.uniques);
862
978
  }
979
+ if (args.uniques !== undefined && args.indexes === undefined) {
980
+ const prunedExistingIndexes = pruneIndexesThatOverlapUniques(indexes ?? [], uniques ?? []);
981
+ indexes = prunedExistingIndexes.indexes;
982
+ body.indexes = indexes;
983
+ schemaConstraintNormalization = {
984
+ prunedExistingIndexes: prunedExistingIndexes.removed,
985
+ reason: 'Unique constraints already provide indexed lookups; MCP removed existing non-unique indexes that used fields now covered by uniques.',
986
+ };
987
+ }
863
988
  assertIndexesDoNotReferenceUniqueFields(indexes ?? [], uniques ?? []);
864
989
  }
865
990
  const result = await patchTableAutoConfirm(ENFYRA_API_URL, args.tableId, body);
866
991
  return {
867
992
  action: 'table_updated',
868
993
  tableId: args.tableId,
994
+ schemaConstraintNormalization,
869
995
  result,
870
996
  };
871
997
  }
@@ -937,14 +1063,17 @@ export function registerTableTools(server, ENFYRA_API_URL) {
937
1063
  };
938
1064
  }
939
1065
  // ─── READ ───
940
- server.tool('get_all_tables', 'List table definitions from metadata. Every call must pass either limit or all=true. Use search to narrow by table name or alias.', {
941
- limit: z.number().int().positive().optional().describe('Maximum tables returned after search. Required unless all=true.'),
1066
+ server.tool('get_all_tables', 'List table definitions from metadata. Complete lists must pass either limit or all=true. If search is provided without limit, the tool returns a bounded lookup window of 10 matches.', {
1067
+ limit: z.number().int().positive().optional().describe('Maximum tables returned after search. Required unless all=true or search is provided.'),
942
1068
  all: z.boolean().optional().describe('Return all matched tables. Use this when a complete table list is required.'),
943
1069
  search: z.string().optional().describe('Optional table name, alias, or description substring filter.'),
944
1070
  }, async ({ limit, all, search }) => {
945
- if (!all && limit === undefined) {
1071
+ if (!all && limit === undefined && !search?.trim()) {
946
1072
  throw new Error('get_all_tables requires either limit or all=true. Do not invent arbitrary limits for complete table lists; use all=true.');
947
1073
  }
1074
+ if (all && limit !== undefined) {
1075
+ throw new Error('get_all_tables accepts either all=true or limit, not both.');
1076
+ }
948
1077
  const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
949
1078
  const needle = search?.trim().toLowerCase();
950
1079
  const tables = normalizeTablesFromMetadata(metadata)
@@ -964,13 +1093,16 @@ export function registerTableTools(server, ENFYRA_API_URL) {
964
1093
  return [table.name, table.alias, table.description]
965
1094
  .some((value) => String(value || '').toLowerCase().includes(needle));
966
1095
  });
967
- const returnedTables = all ? tables : tables.slice(0, limit);
1096
+ const effectiveLimit = all ? tables.length : (limit ?? 10);
1097
+ const returnedTables = all ? tables : tables.slice(0, effectiveLimit);
968
1098
  return jsonContent({
969
1099
  action: 'get_all_tables',
970
1100
  totalTableCount: normalizeTablesFromMetadata(metadata).length,
971
1101
  matchedTableCount: tables.length,
972
1102
  returnedTableCount: returnedTables.length,
973
1103
  all: Boolean(all),
1104
+ implicitSearchLimit: Boolean(!all && limit === undefined && search?.trim()),
1105
+ hardCap: all ? null : effectiveLimit,
974
1106
  search: search || null,
975
1107
  tables: returnedTables,
976
1108
  detailHint: 'Use inspect_table with a table id/name for columns, relations, indexes, routes, permissions, and GraphQL state.',
@@ -1071,6 +1203,8 @@ export function registerTableTools(server, ENFYRA_API_URL) {
1071
1203
  'Do not include id, _id, createdAt, or updatedAt in columns; Enfyra manages them and create_tables strips them before save.',
1072
1204
  '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.',
1073
1205
  'Use get_schema_design_context first for live column types and relation rules. Do not include physical FK fields.',
1206
+ 'The response includes cleanupHints.recordCreateOrder; use it when seeding records so parent/target rows are created before child/source rows.',
1207
+ 'The response includes cleanupHints.recordDeleteOrder; use it when deleting seeded test records so child/source rows are removed before parent/target rows.',
1074
1208
  ].join(' '), {
1075
1209
  items: bulkObjectArrayParam(z, 'Table definitions').optional().describe('Native JSON array of table definitions. Pass one object in the array for a single table.'),
1076
1210
  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.'),
@@ -1083,6 +1217,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
1083
1217
  const parsedItems = parseBulkItemsParam('items', items ?? tables);
1084
1218
  assertBulkLimit('create_tables', parsedItems, maxItems);
1085
1219
  preflightCreateTableDefinitions(parsedItems);
1220
+ const recordDeleteOrder = computeBatchCleanupOrder(parsedItems);
1086
1221
  const created = [];
1087
1222
  const deferredRelations = [];
1088
1223
  const deferredConstraints = [];
@@ -1101,6 +1236,11 @@ export function registerTableTools(server, ENFYRA_API_URL) {
1101
1236
  }
1102
1237
  }
1103
1238
  const createdRelations = [];
1239
+ deferredRelations.sort((left, right) => {
1240
+ const leftPriority = normalizeRelationType(left.type) === 'one-to-many' ? 1 : 0;
1241
+ const rightPriority = normalizeRelationType(right.type) === 'one-to-many' ? 1 : 0;
1242
+ return leftPriority - rightPriority;
1243
+ });
1104
1244
  for (const relation of deferredRelations) {
1105
1245
  const relationResult = await appendRelationToTable({
1106
1246
  sourceTableId: relation.sourceTableId,
@@ -1136,6 +1276,14 @@ export function registerTableTools(server, ENFYRA_API_URL) {
1136
1276
  sequential: true,
1137
1277
  relationPhaseAfterTables: true,
1138
1278
  constraintPhaseAfterRelations: true,
1279
+ cleanupHints: {
1280
+ recordCreateOrder: [...recordDeleteOrder].reverse(),
1281
+ recordDeleteOrder,
1282
+ tableDeleteOrder: recordDeleteOrder,
1283
+ recordCreateRule: 'When seeding sample data, create records sequentially in recordCreateOrder; parent/target rows must exist before child/source rows reference them.',
1284
+ recordRule: 'If you delete seeded records before deleting test tables, delete record batches sequentially in recordDeleteOrder; do not parallelize parent/child deletes.',
1285
+ tableRule: 'For full test cleanup, prefer delete_tables with tableDeleteOrder after deleting custom routes/flows; table deletion removes the remaining table data.',
1286
+ },
1139
1287
  created,
1140
1288
  createdRelations,
1141
1289
  appliedDeferredConstraints,