@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.
@@ -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 string is accepted only for older clients.`);
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 = JSON.parse(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, targetTableId, 'targetTableId');
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 (e.g., enum values).'),
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: normalizedUserColumns, normalizations } = normalizeColumnsForLiveMetadata(userColumns, supportedTypes);
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 = indexes;
751
+ body.indexes = splitIndexes.immediate;
637
752
  if (args.uniques !== undefined)
638
- body.uniques = 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 = typeof options === 'string' ? JSON.parse(options) : 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. Insert records using column names and relation propertyName values, never hidden FK columns.',
896
- '6. Re-inspect each table with inspect_table before writing records or adding query examples.',
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
- const parsedItems = parseBulkItemsParam('items', items);
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
- const parsedItems = parseBulkItemsParam('items', items);
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
- const parsedItems = parseBulkItemsParam('items', items);
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()) {