@enfyra/mcp-server 0.1.19 → 0.1.21

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.
@@ -5,6 +5,9 @@ import { z } from 'zod';
5
5
  import { fetchAPI } from './fetch.js';
6
6
  import { jsonContent } from './response-format.js';
7
7
  import { assertGlobalRulesAck, globalRulesAckParam } from './required-knowledge.js';
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.`);
10
+ }
8
11
  let schemaQueue = Promise.resolve();
9
12
  function withSchemaQueue(operation) {
10
13
  const run = schemaQueue.then(operation, operation);
@@ -127,6 +130,26 @@ function parseJsonArrayParam(name, value) {
127
130
  }
128
131
  return parsed;
129
132
  }
133
+ function parseBulkItemsParam(name, value) {
134
+ const parsed = typeof value === 'string' ? JSON.parse(value) : value;
135
+ if (!Array.isArray(parsed)) {
136
+ throw new Error(`${name} must be a JSON array. Pass one object in the array for a single mutation.`);
137
+ }
138
+ if (parsed.length === 0) {
139
+ throw new Error(`${name} must include at least one item.`);
140
+ }
141
+ parsed.forEach((item, index) => {
142
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
143
+ throw new Error(`${name}[${index}] must be an object.`);
144
+ }
145
+ });
146
+ return parsed;
147
+ }
148
+ function assertBulkLimit(name, items, maxItems) {
149
+ if (items.length > maxItems) {
150
+ throw new Error(`${name} received ${items.length} items, above maxItems=${maxItems}. Split the batch deliberately.`);
151
+ }
152
+ }
130
153
  function normalizeConstraintGroups(name, groups) {
131
154
  return groups.map((group, index) => {
132
155
  const value = Array.isArray(group) ? group : group?.value;
@@ -188,7 +211,7 @@ export function normalizeRelationForTablePatch(relation) {
188
211
  function assertNoForbiddenRelationKeys(args) {
189
212
  for (const key of FORBIDDEN_RELATION_KEYS) {
190
213
  if (Object.prototype.hasOwnProperty.call(args, key)) {
191
- 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.`);
214
+ throw new Error(`create_relations must not include physical column field "${key}". Use sourceTableId/targetTableId and relation propertyName only; Enfyra derives FK and junction columns.`);
192
215
  }
193
216
  }
194
217
  }
@@ -483,7 +506,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
483
506
  targetColumn: target,
484
507
  preservedColumnIds: beforeIds.filter((id) => id !== String(columnId)),
485
508
  destructive: true,
486
- next: 'Call delete_column again with confirm=true to drop the physical column and metadata.',
509
+ next: 'Call delete_columns again with the same one-item array and confirm=true to drop the physical column and metadata.',
487
510
  }, null, 2) }],
488
511
  };
489
512
  }
@@ -524,7 +547,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
524
547
  targetRelation: target,
525
548
  preservedRelationIds: beforeIds.filter((id) => id !== String(relationId)),
526
549
  destructive: true,
527
- next: 'Call delete_relation again with confirm=true to drop relation metadata and any derived FK/junction structures.',
550
+ next: 'Call delete_relations again with the same one-item array and confirm=true to drop relation metadata and any derived FK/junction structures.',
528
551
  }, null, 2) }],
529
552
  };
530
553
  }
@@ -545,7 +568,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
545
568
  });
546
569
  }
547
570
  const columnCreateSchema = {
548
- tableId: z.string().describe('Table definition ID (from get_all_tables or create_table).'),
571
+ tableId: z.string().describe('Table definition ID (from get_all_tables or create_tables).'),
549
572
  name: z.string().describe('Column name (e.g., "title", "webhook_secret"). Lowercase with underscores.'),
550
573
  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.'),
551
574
  isNullable: z.boolean().optional().default(true).describe('Set to false if column cannot be null.'),
@@ -553,7 +576,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
553
576
  isPublished: z.boolean().optional().describe('Set column visibility baseline. Use false for secrets and internal fields.'),
554
577
  isUpdatable: z.boolean().optional().describe('Set false for immutable fields that cannot be updated after creation. Independent from isEncrypted.'),
555
578
  isEncrypted: z.boolean().optional().describe('Set true to encrypt this column at the Enfyra database-query layer. This does not change isUpdatable. Encrypted fields cannot be filtered or sorted.'),
556
- isPrimary: z.boolean().optional().describe('Set true only for primary key columns; normally only create_table auto id uses this.'),
579
+ isPrimary: z.boolean().optional().describe('Set true only for primary key columns; normally only create_tables auto id uses this.'),
557
580
  isGenerated: z.boolean().optional().describe('Set true only for generated columns such as auto id.'),
558
581
  isSystem: z.boolean().optional().describe('Set true only for system-managed columns. Avoid for normal app fields.'),
559
582
  defaultValue: z.string().optional().describe('Default value as JSON string or backend-supported literal.'),
@@ -592,6 +615,175 @@ export function registerTableTools(server, ENFYRA_API_URL) {
592
615
  confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
593
616
  globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when confirm=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
594
617
  };
618
+ function arrayValue(name, value) {
619
+ if (value === undefined || value === null || value === '')
620
+ return [];
621
+ return Array.isArray(value) ? value : parseJsonArrayParam(name, value);
622
+ }
623
+ async function createOneTable(args) {
624
+ const idColumn = { name: 'id', type: 'int', isPrimary: true, isGenerated: true, isNullable: false };
625
+ const userColumns = arrayValue('columns', args.columns);
626
+ const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
627
+ const supportedTypes = getSupportedColumnTypesFromMetadata(metadata);
628
+ const { columns: normalizedUserColumns, normalizations } = normalizeColumnsForLiveMetadata(userColumns, supportedTypes);
629
+ const indexes = normalizeConstraintGroupsValue('indexes', args.indexes ?? []);
630
+ const uniques = normalizeConstraintGroupsValue('uniques', args.uniques ?? []);
631
+ assertIndexesDoNotReferenceUniqueFields(indexes, uniques);
632
+ const body = { name: args.name, description: args.description, columns: [idColumn, ...normalizedUserColumns], relations: [] };
633
+ if (args.isSingleRecord !== undefined)
634
+ body.isSingleRecord = args.isSingleRecord;
635
+ if (args.indexes !== undefined)
636
+ body.indexes = indexes;
637
+ if (args.uniques !== undefined)
638
+ body.uniques = uniques;
639
+ const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_table', {
640
+ method: 'POST',
641
+ body: JSON.stringify(body),
642
+ });
643
+ const createdTable = Array.isArray(result?.data) ? result.data[0] : result;
644
+ const createdTableId = createdTable?.id ?? createdTable?._id;
645
+ const liveMetadataAfterCreate = await fetchAPI(ENFYRA_API_URL, `/metadata/${encodeURIComponent(args.name)}`)
646
+ .catch((error) => ({ error: error?.message || String(error) }));
647
+ const liveTableAfterCreate = liveMetadataAfterCreate?.error
648
+ ? null
649
+ : liveMetadataAfterCreate?.data?.table || liveMetadataAfterCreate?.data || liveMetadataAfterCreate?.table || liveMetadataAfterCreate;
650
+ const liveSchema = summarizeCreatedTableSchema(liveTableAfterCreate, args.name);
651
+ const deferredRelations = arrayValue('relations', args.relations).map(normalizeRelationForTablePatch);
652
+ const routePath = `/${args.name}`;
653
+ return {
654
+ action: 'table_created',
655
+ table: { id: createdTableId, name: args.name, routePath },
656
+ summary: {
657
+ columnCount: normalizedUserColumns.length + 1,
658
+ createdColumnCount: normalizedUserColumns.length,
659
+ deferredRelationCount: deferredRelations.length,
660
+ indexGroupCount: indexes.length,
661
+ uniqueGroupCount: uniques.length,
662
+ },
663
+ schemaNormalization: normalizations,
664
+ schema: {
665
+ intended: {
666
+ tableName: args.name,
667
+ primaryKey: idColumn.name,
668
+ fields: [idColumn.name, ...normalizedUserColumns.map((column) => column.name), ...deferredRelations.map((relation) => relation.propertyName)].filter(Boolean).sort(),
669
+ columns: [idColumn.name, ...normalizedUserColumns.map((column) => column.name)].filter(Boolean),
670
+ relations: deferredRelations.map((relation) => relation.propertyName).filter(Boolean),
671
+ },
672
+ live: liveSchema,
673
+ liveMetadataAvailable: Boolean(liveSchema),
674
+ liveMetadataError: liveMetadataAfterCreate?.error || undefined,
675
+ },
676
+ supportedColumnTypes: supportedTypes,
677
+ rest: {
678
+ base: apiBase,
679
+ routePath,
680
+ operations: ['GET /<table>', 'POST /<table>', 'PATCH /<table>/:id', 'DELETE /<table>/:id'],
681
+ noGetById: true,
682
+ },
683
+ deferredRelations,
684
+ result,
685
+ };
686
+ }
687
+ async function updateOneTable(args) {
688
+ const body = {};
689
+ if (args.name !== undefined)
690
+ body.name = args.name;
691
+ if (args.alias !== undefined)
692
+ body.alias = args.alias;
693
+ if (args.description !== undefined)
694
+ body.description = args.description;
695
+ if (args.isSingleRecord !== undefined)
696
+ body.isSingleRecord = args.isSingleRecord;
697
+ if (args.graphqlEnabled !== undefined)
698
+ body.graphqlEnabled = args.graphqlEnabled;
699
+ if (args.indexes !== undefined)
700
+ body.indexes = normalizeConstraintGroupsValue('indexes', args.indexes);
701
+ if (args.uniques !== undefined)
702
+ body.uniques = normalizeConstraintGroupsValue('uniques', args.uniques);
703
+ if (args.indexes !== undefined || args.uniques !== undefined) {
704
+ let indexes = body.indexes;
705
+ let uniques = body.uniques;
706
+ if (indexes === undefined || uniques === undefined) {
707
+ const existing = await fetchTableWithDetails(ENFYRA_API_URL, args.tableId);
708
+ if (indexes === undefined)
709
+ indexes = normalizeConstraintGroupsValue('indexes', existing.indexes);
710
+ if (uniques === undefined)
711
+ uniques = normalizeConstraintGroupsValue('uniques', existing.uniques);
712
+ }
713
+ assertIndexesDoNotReferenceUniqueFields(indexes ?? [], uniques ?? []);
714
+ }
715
+ const result = await patchTableAutoConfirm(ENFYRA_API_URL, args.tableId, body);
716
+ return {
717
+ action: 'table_updated',
718
+ tableId: args.tableId,
719
+ result,
720
+ };
721
+ }
722
+ async function deleteOneTable({ tableId, confirm }) {
723
+ const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
724
+ if (!confirm) {
725
+ return {
726
+ action: 'delete_table_preview',
727
+ tableId,
728
+ tableName: tableData.name,
729
+ columnCount: (tableData.columns || []).length,
730
+ relationCount: (tableData.relations || []).length,
731
+ destructive: true,
732
+ };
733
+ }
734
+ const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_table/${tableId}`, {
735
+ method: 'DELETE',
736
+ });
737
+ return {
738
+ action: 'table_deleted',
739
+ tableId,
740
+ result,
741
+ };
742
+ }
743
+ async function updateOneColumn({ tableId, columnId, name, type, isNullable, isPublished, isUpdatable, defaultValue, description, options }) {
744
+ const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
745
+ if (!tableData) {
746
+ throw new Error(`Table with ID ${tableId} not found.`);
747
+ }
748
+ const existingColumns = getPatchableColumns(tableData.columns);
749
+ const beforeIds = existingColumns.map((column) => String(getId(column)));
750
+ if (!beforeIds.includes(String(columnId))) {
751
+ throw new Error(`Column ${columnId} was not found on table ${tableId}; refusing schema cascade patch.`);
752
+ }
753
+ const columns = existingColumns.map(col => {
754
+ const rest = normalizeColumnForTablePatch(col);
755
+ if (String(getId(col)) === String(columnId)) {
756
+ if (name !== undefined)
757
+ rest.name = name;
758
+ if (type !== undefined)
759
+ rest.type = type;
760
+ if (isNullable !== undefined)
761
+ rest.isNullable = isNullable;
762
+ if (isPublished !== undefined)
763
+ rest.isPublished = isPublished;
764
+ if (isUpdatable !== undefined)
765
+ rest.isUpdatable = isUpdatable;
766
+ if (defaultValue !== undefined)
767
+ rest.defaultValue = defaultValue;
768
+ if (description !== undefined)
769
+ rest.description = description;
770
+ if (options !== undefined)
771
+ rest.options = typeof options === 'string' ? JSON.parse(options) : options;
772
+ }
773
+ return rest;
774
+ });
775
+ const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, { columns });
776
+ await verifyColumnCascade(ENFYRA_API_URL, tableId, beforeIds, {
777
+ action: 'update',
778
+ columnId,
779
+ });
780
+ return {
781
+ action: 'column_updated',
782
+ tableId,
783
+ columnId,
784
+ result,
785
+ };
786
+ }
595
787
  // ─── READ ───
596
788
  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.', {
597
789
  limit: z.number().int().positive().optional().describe('Maximum tables returned after search. Required unless all=true.'),
@@ -652,19 +844,19 @@ export function registerTableTools(server, ENFYRA_API_URL) {
652
844
  const primaryColumnTypes = [...new Set(primaryColumns.map((column) => column.type).filter(Boolean))];
653
845
  return jsonContent({
654
846
  action: 'schema_design_context',
655
- stepZero: 'Read this response before create_table/create_column/create_relation. Use these live attributes and types, not SQL dialect guesses.',
847
+ stepZero: 'Read this response before create_tables/create_columns/create_relations. Use these live attributes and types, not SQL dialect guesses.',
656
848
  liveColumnTypes,
657
849
  primaryKeyContext: {
658
850
  observedPrimaryColumnNames: primaryColumnNames,
659
851
  observedPrimaryColumnTypes: primaryColumnTypes,
660
- createTableDefault: 'This MCP create_table currently auto-includes an int id primary key for the default SQL path. If live metadata uses _id/ObjectId, stop and verify backend support before bulk schema creation.',
852
+ createTableDefault: 'This MCP create_tables currently auto-includes an int id primary key for the default SQL path. If live metadata uses _id/ObjectId, stop and verify backend support before bulk schema creation.',
661
853
  },
662
854
  createTableInput: {
663
855
  directFields: ['name', 'description', 'isSingleRecord'],
664
856
  cascadeFields: ['columns', 'relations'],
665
857
  constraintFields: ['indexes', 'uniques'],
666
858
  notAcceptedAtCreate: ['alias', 'graphqlEnabled'],
667
- idColumn: 'create_table auto-includes an int id primary key; do not include your own id unless the user asks for custom primary-key behavior.',
859
+ 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.',
668
860
  },
669
861
  columnDefinitionInput: {
670
862
  allowedFields: ['name', 'type', 'isNullable', 'isUnique', 'isPublished', 'isUpdatable', 'isEncrypted', 'defaultValue', 'description', 'options'],
@@ -699,7 +891,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
699
891
  '1. Name domain entities and decide which existing tables are reused, especially enfyra_user for users/owners/actors.',
700
892
  '2. Create independent lookup/base tables first with scalar columns only.',
701
893
  '3. Create dependent tables with scalar columns and relations whose target tables already exist.',
702
- '4. Use create_relation after both tables exist when a relation could not be included during table creation.',
894
+ '4. Use create_relations after both tables exist when a relation could not be included during table creation.',
703
895
  '5. Insert records using column names and relation propertyName values, never hidden FK columns.',
704
896
  '6. Re-inspect each table with inspect_table before writing records or adding query examples.',
705
897
  ],
@@ -710,303 +902,206 @@ export function registerTableTools(server, ENFYRA_API_URL) {
710
902
  },
711
903
  });
712
904
  });
713
- // ─── CREATE TABLE ───
714
- server.tool('create_table', [
715
- 'Create a new table definition with an auto-included `id` primary key column.',
716
- '**Not** for adding a custom API path or handler only for that use **`create_route`** without `mainTableId`. Use **`create_table`** when the user needs new stored data (new entity).',
717
- 'PREFERRED: pass `columns` and `relations` params as JSON arrays to create a table WITH columns and relations in one call (cascade). Only use create_column/create_relation separately when adding to an existing table later.',
718
- 'Indexes and uniques are first-class table metadata. Use `indexes` for query performance and `uniques` for data integrity. Each entry is a logical field group such as [["member","isRead","conversation"]] or [{"value":["message","member"]}]. Relation property names are allowed; Enfyra resolves them to physical FK columns.',
719
- 'A field that appears in any `uniques` group must not appear in `indexes`; unique constraints already create indexed unique lookups.',
720
- 'Relations are supported in this same create_table call when the target table already exists. Each relation uses { targetTable, type, propertyName, inversePropertyName?, mappedBy?, isNullable?, onDelete? }; targetTable may be a table id, {id}, or an exact table name that MCP resolves to an id before mutation. Omit inversePropertyName unless a concrete parent-to-child query or UI surface needs it.',
721
- 'Do NOT provide physical FK/junction columns. Never include fkCol, fkColumn, foreignKeyColumn, sourceColumn, targetColumn, junctionSourceColumn, or junctionTargetColumn. Enfyra derives and hides those physical columns from relation propertyName/table metadata.',
722
- 'Schema operations (create/update/delete table, add column) must run one at a time — migration locks DB; parallel calls will fail.',
723
- 'Enfyra auto-creates a default REST route at path `/<table_name>` (same segment as `name`, not alias).',
724
- 'REST surface for that route (matches server route engine): 4 HTTP operations — GET `/<table>` (list/filter), POST `/<table>` (create), PATCH `/<table>/:id` (update), DELETE `/<table>/:id` (delete).',
725
- 'There is NO `GET /<table>/:id`. To fetch one row by id, use find_one_record or inspect metadata first and call GET `/<table>?filter={"<primaryKeyFromMetadata>":{"_eq":"<id>"}}&limit=1`.',
726
- 'Set `isSingleRecord: true` directly in create_table for settings/config tables that should keep only one record.',
727
- `Full URLs: ${apiBase}/<table_name> (example table post: ${apiBase}/post).`,
728
- 'GraphQL is enabled separately per table through `enfyra_graphql` or `update_table` with `graphqlEnabled`; it is not controlled by route availableMethods.',
729
- 'Do not set alias during create_table. The create tool accepts name, description, isSingleRecord, columns, and relations only; use update_table later only if alias really needs to change.',
905
+ // ─── TABLE MUTATIONS ───
906
+ server.tool('create_tables', [
907
+ '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
+ '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
+ 'Each item supports { name, description?, isSingleRecord?, columns?, relations?, indexes?, uniques? }. columns/relations/indexes/uniques may be arrays inside the item.',
910
+ 'Use get_schema_design_context first for live column types and relation rules. Do not include physical FK fields.',
730
911
  ].join(' '), {
731
- name: z.string().describe('Table name (e.g., "enfyra_user", "my_custom_table"). Must be unique, lowercase with underscores.'),
732
- description: z.string().optional().describe('Description of what this table stores.'),
733
- isSingleRecord: z.boolean().optional().describe('Set to true for single-record tables such as settings/config. This is passed directly to enfyra_table create.'),
734
- columns: z.string().optional().describe('JSON array of column definitions to create with the table (cascade). Each column: { name, type, isNullable?, isUnique?, isPublished?, isUpdatable?, isEncrypted?, defaultValue?, description?, options? }. Type must come from live enfyra_column.type; common choices: varchar, text, boolean, int, bigint, float, date, datetime, timestamp, enum, simple-json, code, array-select, richtext. The tool normalizes common aliases: decimal/numeric/money/number -> float when decimal is not live-supported, longtext -> text, json/jsonb/object/array -> simple-json when live-supported, string -> varchar. Set isEncrypted=true for values encrypted at rest; set isUpdatable=false separately only when immutable. The `id` column is always auto-included. Prefer relations instead of *_id columns. Example: [{"name":"title","type":"varchar"},{"name":"metadata","type":"simple-json"},{"name":"price","type":"float"},{"name":"api_key","type":"varchar","isEncrypted":true,"isPublished":false}]'),
735
- relations: z.string().optional().describe('JSON array of relation definitions to create with the table in the same cascade call. Each relation: { targetTable, type, propertyName, inversePropertyName?, mappedBy?, isNullable?, onDelete?, description? }. targetTable can be an id, {"id": <id>}, or an exact table name that MCP resolves to an id before mutation. Do not include physical FK/junction columns such as fkCol, foreignKeyColumn, sourceColumn, targetColumn, junctionSourceColumn, or junctionTargetColumn; Enfyra derives them and hides FK columns from app schema. Omit inversePropertyName unless a concrete response, UI, deep query, aggregate sort/count, or parent-to-child traversal needs the reverse field. Example only when parent posts are queried: [{"targetTable":2,"type":"many-to-one","propertyName":"author","inversePropertyName":"posts","isNullable":false,"onDelete":"CASCADE"}]'),
736
- indexes: z.string().optional().describe('JSON array of logical non-unique index field groups. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Relation property names are allowed. Do not include any field that appears in uniques; unique constraints already create indexed unique lookups. Example: [["status","createdAt"]]'),
737
- uniques: z.string().optional().describe('JSON array of logical unique field groups. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Example: [["message","member"]]'),
912
+ items: bulkObjectArrayParam(z, 'Table definitions').describe('Native JSON array of table definitions. Pass one object in the array for a single table.'),
913
+ 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.'),
738
914
  globalRulesAckKey: globalRulesAckParam(z),
739
- }, async ({ name, description, isSingleRecord, columns: columnsJson, relations: relationsJson, indexes: indexesJson, uniques: uniquesJson, globalRulesAckKey }) => withSchemaQueue(async () => {
915
+ }, async ({ items, maxItems, globalRulesAckKey }) => {
740
916
  assertGlobalRulesAck(globalRulesAckKey);
741
- const idColumn = { name: 'id', type: 'int', isPrimary: true, isGenerated: true, isNullable: false };
742
- const userColumns = parseJsonArrayParam('columns', columnsJson);
743
- const parsedRelations = parseJsonArrayParam('relations', relationsJson).map(normalizeRelationForTablePatch);
744
- const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
745
- const supportedTypes = getSupportedColumnTypesFromMetadata(metadata);
746
- const { columns: normalizedUserColumns, normalizations } = normalizeColumnsForLiveMetadata(userColumns, supportedTypes);
747
- const userRelations = resolveRelationTargetsFromMetadata(metadata, parsedRelations);
748
- const indexes = parseConstraintGroupsParam('indexes', indexesJson);
749
- const uniques = parseConstraintGroupsParam('uniques', uniquesJson);
750
- assertIndexesDoNotReferenceUniqueFields(indexes, uniques);
751
- const body = { name, description, columns: [idColumn, ...normalizedUserColumns], relations: userRelations };
752
- if (isSingleRecord !== undefined)
753
- body.isSingleRecord = isSingleRecord;
754
- if (indexesJson !== undefined)
755
- body.indexes = indexes;
756
- if (uniquesJson !== undefined)
757
- body.uniques = uniques;
758
- const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_table', {
759
- method: 'POST',
760
- body: JSON.stringify(body),
761
- });
762
- const createdTable = Array.isArray(result?.data) ? result.data[0] : result;
763
- const createdTableId = createdTable?.id ?? createdTable?._id;
764
- const liveMetadataAfterCreate = await fetchAPI(ENFYRA_API_URL, `/metadata/${encodeURIComponent(name)}`)
765
- .catch((error) => ({ error: error?.message || String(error) }));
766
- const liveTableAfterCreate = liveMetadataAfterCreate?.error
767
- ? null
768
- : liveMetadataAfterCreate?.data?.table || liveMetadataAfterCreate?.data || liveMetadataAfterCreate?.table || liveMetadataAfterCreate;
769
- const liveSchema = summarizeCreatedTableSchema(liveTableAfterCreate, name);
770
- const intendedSchema = {
771
- tableName: name,
772
- primaryKey: idColumn.name,
773
- fields: [idColumn.name, ...normalizedUserColumns.map((column) => column.name), ...userRelations.map((relation) => relation.propertyName)].filter(Boolean).sort(),
774
- columns: [idColumn.name, ...normalizedUserColumns.map((column) => column.name)].filter(Boolean),
775
- relations: userRelations.map((relation) => relation.propertyName).filter(Boolean),
776
- };
777
- const base = ENFYRA_API_URL.replace(/\/$/, '');
778
- const routePath = `/${name}`;
779
- const restHint = [
780
- `Auto route path: ${routePath} → full base for REST: ${base}${routePath}`,
781
- `REST: GET+POST on ${routePath}; PATCH+DELETE on ${routePath}/:id only. No GET ${routePath}/:id.`,
782
- ].join('\n');
783
- const colHint = userColumns.length
784
- ? `Table created with ${userColumns.length} column(s) + auto id.`
785
- : `Table created. Use create_column to add columns (tableId: ${createdTableId}).`;
786
- const relHint = userRelations.length
787
- ? `Relation(s) created in same call: ${userRelations.length}.`
788
- : `No relations were included in this create_table call.`;
789
- const constraintHint = [
790
- indexes.length ? `Index group(s): ${indexes.length}.` : null,
791
- uniques.length ? `Unique group(s): ${uniques.length}.` : null,
792
- ].filter(Boolean).join(' ');
917
+ const parsedItems = parseBulkItemsParam('items', items);
918
+ assertBulkLimit('create_tables', parsedItems, maxItems);
919
+ const created = [];
920
+ const deferredRelations = [];
921
+ for (const [index, item] of parsedItems.entries()) {
922
+ const result = await withSchemaQueue(() => createOneTable(item));
923
+ created.push({ index, ...result, deferredRelations: undefined });
924
+ for (const relation of result.deferredRelations || []) {
925
+ deferredRelations.push({ index, sourceTableId: result.table.id || result.table.name, ...relation });
926
+ }
927
+ }
928
+ const createdRelations = [];
929
+ for (const relation of deferredRelations) {
930
+ const relationResult = await appendRelationToTable({
931
+ sourceTableId: relation.sourceTableId,
932
+ targetTableId: relation.targetTable,
933
+ type: relation.type,
934
+ propertyName: relation.propertyName,
935
+ inversePropertyName: relation.inversePropertyName,
936
+ mappedBy: relation.mappedBy,
937
+ isNullable: relation.isNullable,
938
+ onDelete: relation.onDelete,
939
+ description: relation.description,
940
+ globalRulesAckKey,
941
+ });
942
+ createdRelations.push({ index: relation.index, ...JSON.parse(relationResult.content[0].text) });
943
+ }
793
944
  return jsonContent({
794
- action: 'table_created',
795
- table: { id: createdTableId, name, routePath },
796
- summary: {
797
- columnCount: normalizedUserColumns.length + 1,
798
- createdColumnCount: normalizedUserColumns.length,
799
- relationCount: userRelations.length,
800
- indexGroupCount: indexes.length,
801
- uniqueGroupCount: uniques.length,
802
- },
803
- schemaNormalization: normalizations,
804
- schema: {
805
- intended: intendedSchema,
806
- live: liveSchema,
807
- liveMetadataAvailable: Boolean(liveSchema),
808
- liveMetadataError: liveMetadataAfterCreate?.error || undefined,
809
- guidance: liveSchema
810
- ? 'Use schema.live.fields for record payload keys; relation property names are included with columns.'
811
- : 'Use schema.intended.fields for planning, then call inspect_table before writing records if the server reload is not visible yet.',
812
- },
813
- supportedColumnTypes: supportedTypes,
814
- rest: {
815
- base,
816
- routePath,
817
- operations: ['GET /<table>', 'POST /<table>', 'PATCH /<table>/:id', 'DELETE /<table>/:id'],
818
- noGetById: true,
819
- },
820
- message: [colHint, relHint, constraintHint, restHint].filter(Boolean).join('\n'),
821
- result,
945
+ action: 'tables_created',
946
+ requested: parsedItems.length,
947
+ createdCount: created.length,
948
+ deferredRelationCount: deferredRelations.length,
949
+ createdRelationCount: createdRelations.length,
950
+ sequential: true,
951
+ relationPhaseAfterTables: true,
952
+ created,
953
+ createdRelations,
822
954
  });
823
- }));
824
- // ─── UPDATE TABLE ───
825
- server.tool('update_table', [
826
- 'Update table properties: name (rename), alias, description, isSingleRecord, graphqlEnabled, indexes, and uniques.',
827
- 'Does NOT modify columns or relations — use create_column, update_column, delete_column, create_relation for those.',
828
- 'When passing `indexes` or `uniques`, pass the complete desired array of logical field groups; omitted fields are preserved. Relation property names are allowed and are resolved by Enfyra. Example indexes: [["member","isRead","conversation"],["conversation","member","isRead"]].',
829
- 'A field that appears in any `uniques` group must not appear in `indexes`; unique constraints already create indexed unique lookups.',
830
- 'Run schema changes sequentially — migration locks DB per operation.',
831
- ].join(' '), {
832
- tableId: z.string().describe('Table definition ID.'),
833
- name: z.string().optional().describe('New table name (rename). Lowercase with underscores.'),
834
- alias: z.string().optional().describe('New table alias.'),
835
- description: z.string().optional().describe('New description.'),
836
- isSingleRecord: z.boolean().optional().describe('Set to true for single-record table (e.g., settings/config).'),
837
- 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.'),
838
- indexes: z.string().optional().describe('Complete JSON array of logical non-unique 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. Do not include any field that appears in uniques; unique constraints already create indexed unique lookups.'),
839
- 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.'),
955
+ });
956
+ 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.', {
957
+ items: bulkObjectArrayParam(z, 'Table update items').describe('Native JSON array of table update items: [{ tableId, name?, alias?, description?, isSingleRecord?, graphqlEnabled?, indexes?, uniques? }]. indexes/uniques may be arrays.'),
958
+ maxItems: z.number().int().min(1).max(100).optional().default(100).describe('Safety cap for one schema batch. Default/max is 100.'),
840
959
  globalRulesAckKey: globalRulesAckParam(z),
841
- }, async ({ tableId, name, alias, description, isSingleRecord, graphqlEnabled, indexes: indexesJson, uniques: uniquesJson, globalRulesAckKey }) => withSchemaQueue(async () => {
960
+ }, async ({ items, maxItems, globalRulesAckKey }) => {
842
961
  assertGlobalRulesAck(globalRulesAckKey);
843
- const body = {};
844
- if (name !== undefined)
845
- body.name = name;
846
- if (alias !== undefined)
847
- body.alias = alias;
848
- if (description !== undefined)
849
- body.description = description;
850
- if (isSingleRecord !== undefined)
851
- body.isSingleRecord = isSingleRecord;
852
- if (graphqlEnabled !== undefined)
853
- body.graphqlEnabled = graphqlEnabled;
854
- if (indexesJson !== undefined)
855
- body.indexes = parseConstraintGroupsParam('indexes', indexesJson);
856
- if (uniquesJson !== undefined)
857
- body.uniques = parseConstraintGroupsParam('uniques', uniquesJson);
858
- if (indexesJson !== undefined || uniquesJson !== undefined) {
859
- let indexes = body.indexes;
860
- let uniques = body.uniques;
861
- if (indexes === undefined || uniques === undefined) {
862
- const existing = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
863
- if (indexes === undefined)
864
- indexes = normalizeConstraintGroupsValue('indexes', existing.indexes);
865
- if (uniques === undefined)
866
- uniques = normalizeConstraintGroupsValue('uniques', existing.uniques);
867
- }
868
- assertIndexesDoNotReferenceUniqueFields(indexes ?? [], uniques ?? []);
962
+ const parsedItems = parseBulkItemsParam('items', items);
963
+ assertBulkLimit('update_tables', parsedItems, maxItems);
964
+ const updated = [];
965
+ for (const [index, item] of parsedItems.entries()) {
966
+ if (!item.tableId)
967
+ throw new Error(`items[${index}].tableId is required.`);
968
+ const result = await withSchemaQueue(() => updateOneTable(item));
969
+ updated.push({ index, ...result });
970
+ }
971
+ return jsonContent({ action: 'tables_updated', requested: parsedItems.length, updatedCount: updated.length, sequential: true, updated });
972
+ });
973
+ server.tool('delete_tables', 'Delete one or more table definitions. Always pass items as a native JSON array; for one table, pass one item. confirm=false previews every target; confirm=true deletes sequentially. JSON string arrays are accepted only for older MCP clients.', {
974
+ items: bulkObjectArrayParam(z, 'Table delete items').describe('Native JSON array of delete items: [{ tableId }].'),
975
+ maxItems: z.number().int().min(1).max(100).optional().default(100).describe('Safety cap for one schema batch. Default/max is 100.'),
976
+ confirm: z.boolean().optional().default(false).describe('Required true to apply destructive deletes. Omit/false returns previews only.'),
977
+ globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when confirm=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
978
+ }, async ({ items, maxItems, confirm, globalRulesAckKey }) => {
979
+ const parsedItems = parseBulkItemsParam('items', items);
980
+ assertBulkLimit('delete_tables', parsedItems, maxItems);
981
+ if (confirm)
982
+ assertGlobalRulesAck(globalRulesAckKey);
983
+ const results = [];
984
+ for (const [index, item] of parsedItems.entries()) {
985
+ if (!item.tableId)
986
+ throw new Error(`items[${index}].tableId is required.`);
987
+ const result = await withSchemaQueue(() => deleteOneTable({ tableId: item.tableId, confirm }));
988
+ results.push({ index, ...result });
869
989
  }
870
- const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, body);
871
990
  return jsonContent({
872
- action: 'table_updated',
873
- tableId,
874
- result,
991
+ action: confirm ? 'tables_deleted' : 'delete_tables_preview',
992
+ requested: parsedItems.length,
993
+ sequential: true,
994
+ destructive: true,
995
+ results,
996
+ next: confirm ? undefined : 'Call delete_tables again with the same items and confirm=true to delete sequentially.',
875
997
  });
876
- }));
877
- // ─── DELETE TABLE ───
878
- server.tool('delete_table', [
879
- 'Delete a table and ALL associated data. This is DESTRUCTIVE and IRREVERSIBLE.',
880
- 'Deletes: table metadata, all columns, all relations (source + target), all routes, junction tables, FK columns from other tables, and the PHYSICAL DATABASE TABLE with ALL DATA.',
881
- 'Always confirm with the user before calling this tool.',
882
- ].join(' '), {
883
- tableId: z.string().describe('Table definition ID to delete.'),
884
- confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
885
- globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when confirm=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
886
- }, async ({ tableId, confirm, globalRulesAckKey }) => withSchemaQueue(async () => {
887
- const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
888
- if (!confirm) {
889
- return {
890
- content: [{ type: 'text', text: JSON.stringify({
891
- action: 'delete_table_preview',
892
- tableId,
893
- tableName: tableData.name,
894
- columnCount: (tableData.columns || []).length,
895
- relationCount: (tableData.relations || []).length,
896
- destructive: true,
897
- next: 'Call delete_table again with confirm=true to delete metadata, routes, derived FK/junction structures, the physical table, and all table data.',
898
- }, null, 2) }],
899
- };
998
+ });
999
+ // ─── COLUMN MUTATIONS ───
1000
+ 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? }.'),
1002
+ maxItems: z.number().int().min(1).max(100).optional().default(100).describe('Safety cap for one schema batch. Default/max is 100.'),
1003
+ globalRulesAckKey: globalRulesAckParam(z),
1004
+ }, async ({ items, maxItems, globalRulesAckKey }) => {
1005
+ assertGlobalRulesAck(globalRulesAckKey);
1006
+ const parsedItems = parseBulkItemsParam('items', items);
1007
+ assertBulkLimit('create_columns', parsedItems, maxItems);
1008
+ const created = [];
1009
+ for (const [index, item] of parsedItems.entries()) {
1010
+ const result = await appendColumnToTable({ ...item, globalRulesAckKey });
1011
+ created.push({ index, ...JSON.parse(result.content[0].text) });
900
1012
  }
1013
+ return jsonContent({ action: 'columns_created', requested: parsedItems.length, createdCount: created.length, sequential: true, created });
1014
+ });
1015
+ server.tool('update_columns', 'Update 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.', {
1016
+ items: bulkObjectArrayParam(z, 'Column update items').describe('Native JSON array of column update items: [{ tableId, columnId, name?, type?, isNullable?, isPublished?, isUpdatable?, defaultValue?, description?, options? }].'),
1017
+ maxItems: z.number().int().min(1).max(100).optional().default(100).describe('Safety cap for one schema batch. Default/max is 100.'),
1018
+ globalRulesAckKey: globalRulesAckParam(z),
1019
+ }, async ({ items, maxItems, globalRulesAckKey }) => {
901
1020
  assertGlobalRulesAck(globalRulesAckKey);
902
- const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_table/${tableId}`, {
903
- method: 'DELETE',
904
- });
1021
+ const parsedItems = parseBulkItemsParam('items', items);
1022
+ assertBulkLimit('update_columns', parsedItems, maxItems);
1023
+ const updated = [];
1024
+ for (const [index, item] of parsedItems.entries()) {
1025
+ if (!item.tableId)
1026
+ throw new Error(`items[${index}].tableId is required.`);
1027
+ if (!item.columnId)
1028
+ throw new Error(`items[${index}].columnId is required.`);
1029
+ const result = await withSchemaQueue(() => updateOneColumn(item));
1030
+ updated.push({ index, ...result });
1031
+ }
1032
+ return jsonContent({ action: 'columns_updated', requested: parsedItems.length, updatedCount: updated.length, sequential: true, updated });
1033
+ });
1034
+ server.tool('delete_columns', 'Delete one or more columns. Always pass items as a native JSON array; for one column, pass one item. confirm=false previews every target; confirm=true deletes sequentially. JSON string arrays are accepted only for older MCP clients.', {
1035
+ items: bulkObjectArrayParam(z, 'Column delete items').describe('Native JSON array of delete items: [{ tableId, columnId }].'),
1036
+ maxItems: z.number().int().min(1).max(100).optional().default(100).describe('Safety cap for one schema batch. Default/max is 100.'),
1037
+ confirm: z.boolean().optional().default(false).describe('Required true to apply destructive deletes. Omit/false returns previews only.'),
1038
+ globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when confirm=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
1039
+ }, async ({ items, maxItems, confirm, globalRulesAckKey }) => {
1040
+ const parsedItems = parseBulkItemsParam('items', items);
1041
+ assertBulkLimit('delete_columns', parsedItems, maxItems);
1042
+ if (confirm)
1043
+ assertGlobalRulesAck(globalRulesAckKey);
1044
+ const results = [];
1045
+ for (const [index, item] of parsedItems.entries()) {
1046
+ if (!item.tableId)
1047
+ throw new Error(`items[${index}].tableId is required.`);
1048
+ if (!item.columnId)
1049
+ throw new Error(`items[${index}].columnId is required.`);
1050
+ const result = await removeColumnFromTable({ ...item, confirm, globalRulesAckKey });
1051
+ results.push({ index, ...JSON.parse(result.content[0].text) });
1052
+ }
905
1053
  return jsonContent({
906
- action: 'table_deleted',
907
- tableId,
908
- result,
1054
+ action: confirm ? 'columns_deleted' : 'delete_columns_preview',
1055
+ requested: parsedItems.length,
1056
+ sequential: true,
1057
+ destructive: true,
1058
+ results,
1059
+ next: confirm ? undefined : 'Call delete_columns again with the same items and confirm=true to delete sequentially.',
909
1060
  });
910
- }));
911
- // ─── CREATE COLUMN ───
912
- server.tool('create_column', [
913
- 'Add a column to an existing table via PATCH /enfyra_table/{tableId}.',
914
- 'Columns are managed through cascade with enfyra_table — there is NO direct /enfyra_column endpoint.',
915
- 'This tool reads full table metadata, keeps only persisted column rows with id/_id, appends the new one, PATCHes the table, and verifies unrelated columns survived.',
916
- 'Generated metadata projections such as createdAt, updatedAt, or relation-derived FK display fields without id are not valid cascade rows and are skipped.',
917
- 'Run schema changes sequentially — migration locks DB per operation.',
918
- ].join(' '), {
919
- ...columnCreateSchema,
920
- }, appendColumnToTable);
921
- // ─── UPDATE COLUMN ───
922
- server.tool('update_column', [
923
- 'Update an existing column on a table via PATCH /enfyra_table/{tableId}.',
924
- 'Reads full table metadata, keeps only persisted rows with id/_id, modifies the target column, PATCHes the table, and verifies unrelated columns survived.',
925
- 'Generated metadata projections such as createdAt, updatedAt, or relation-derived FK display fields without id are skipped.',
926
- 'Run schema changes sequentially — migration locks DB per operation.',
927
- ].join(' '), {
928
- tableId: z.string().describe('Table definition ID.'),
929
- columnId: z.string().describe('Column definition ID to update.'),
930
- name: z.string().optional().describe('New column name.'),
931
- type: z.string().optional().describe('New column type.'),
932
- isNullable: z.boolean().optional().describe('Set nullable.'),
933
- isPublished: z.boolean().optional().describe('Set column visibility baseline. false = unpublished (omitted from response unless allowed by field permission rules).'),
934
- isUpdatable: z.boolean().optional().describe('Set false for immutable fields that should be stripped from update payloads.'),
935
- defaultValue: z.string().optional().describe('New default value as JSON string.'),
936
- description: z.string().optional().describe('New description.'),
937
- options: z.string().optional().describe('New options as JSON string.'),
1061
+ });
1062
+ // ─── RELATION MUTATIONS ───
1063
+ 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.'),
1065
+ maxItems: z.number().int().min(1).max(100).optional().default(100).describe('Safety cap for one schema batch. Default/max is 100.'),
938
1066
  globalRulesAckKey: globalRulesAckParam(z),
939
- }, async ({ tableId, columnId, name, type, isNullable, isPublished, isUpdatable, defaultValue, description, options, globalRulesAckKey }) => withSchemaQueue(async () => {
1067
+ }, async ({ items, maxItems, globalRulesAckKey }) => {
940
1068
  assertGlobalRulesAck(globalRulesAckKey);
941
- const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
942
- if (!tableData) {
943
- throw new Error(`Table with ID ${tableId} not found.`);
1069
+ const parsedItems = parseBulkItemsParam('items', items);
1070
+ assertBulkLimit('create_relations', parsedItems, maxItems);
1071
+ const created = [];
1072
+ for (const [index, item] of parsedItems.entries()) {
1073
+ const result = await appendRelationToTable({ ...item, globalRulesAckKey });
1074
+ created.push({ index, ...JSON.parse(result.content[0].text) });
944
1075
  }
945
- const existingColumns = getPatchableColumns(tableData.columns);
946
- const beforeIds = existingColumns.map((column) => String(getId(column)));
947
- if (!beforeIds.includes(String(columnId))) {
948
- throw new Error(`Column ${columnId} was not found on table ${tableId}; refusing schema cascade patch.`);
1076
+ return jsonContent({ action: 'relations_created', requested: parsedItems.length, createdCount: created.length, sequential: true, created });
1077
+ });
1078
+ server.tool('delete_relations', 'Delete one or more relations. Always pass items as a native JSON array; for one relation, pass one item. confirm=false previews every target; confirm=true deletes sequentially. JSON string arrays are accepted only for older MCP clients.', {
1079
+ items: bulkObjectArrayParam(z, 'Relation delete items').describe('Native JSON array of delete items: [{ tableId, relationId }].'),
1080
+ maxItems: z.number().int().min(1).max(100).optional().default(100).describe('Safety cap for one schema batch. Default/max is 100.'),
1081
+ confirm: z.boolean().optional().default(false).describe('Required true to apply destructive deletes. Omit/false returns previews only.'),
1082
+ globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when confirm=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
1083
+ }, async ({ items, maxItems, confirm, globalRulesAckKey }) => {
1084
+ const parsedItems = parseBulkItemsParam('items', items);
1085
+ assertBulkLimit('delete_relations', parsedItems, maxItems);
1086
+ if (confirm)
1087
+ assertGlobalRulesAck(globalRulesAckKey);
1088
+ const results = [];
1089
+ for (const [index, item] of parsedItems.entries()) {
1090
+ if (!item.tableId)
1091
+ throw new Error(`items[${index}].tableId is required.`);
1092
+ if (!item.relationId)
1093
+ throw new Error(`items[${index}].relationId is required.`);
1094
+ const result = await removeRelationFromTable({ ...item, confirm, globalRulesAckKey });
1095
+ results.push({ index, ...JSON.parse(result.content[0].text) });
949
1096
  }
950
- const columns = existingColumns.map(col => {
951
- const rest = normalizeColumnForTablePatch(col);
952
- if (String(getId(col)) === String(columnId)) {
953
- if (name !== undefined)
954
- rest.name = name;
955
- if (type !== undefined)
956
- rest.type = type;
957
- if (isNullable !== undefined)
958
- rest.isNullable = isNullable;
959
- if (isPublished !== undefined)
960
- rest.isPublished = isPublished;
961
- if (isUpdatable !== undefined)
962
- rest.isUpdatable = isUpdatable;
963
- if (defaultValue !== undefined)
964
- rest.defaultValue = defaultValue;
965
- if (description !== undefined)
966
- rest.description = description;
967
- if (options !== undefined)
968
- rest.options = JSON.parse(options);
969
- }
970
- return rest;
971
- });
972
- const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, { columns });
973
- await verifyColumnCascade(ENFYRA_API_URL, tableId, beforeIds, {
974
- action: 'update',
975
- columnId,
976
- });
977
1097
  return jsonContent({
978
- action: 'column_updated',
979
- tableId,
980
- columnId,
981
- result,
1098
+ action: confirm ? 'relations_deleted' : 'delete_relations_preview',
1099
+ requested: parsedItems.length,
1100
+ sequential: true,
1101
+ destructive: true,
1102
+ results,
1103
+ next: confirm ? undefined : 'Call delete_relations again with the same items and confirm=true to delete sequentially.',
982
1104
  });
983
- }));
984
- // ─── DELETE COLUMN ───
985
- server.tool('delete_column', [
986
- 'Delete a column from a table via PATCH /enfyra_table/{tableId}.',
987
- 'Reads full table metadata, keeps only persisted rows with id/_id, removes the target, PATCHes the table, and verifies unrelated columns survived.',
988
- 'The physical column is dropped from the database. System columns (id, createdAt, updatedAt) cannot be deleted.',
989
- 'Run schema changes sequentially — migration locks DB per operation.',
990
- ].join(' '), {
991
- ...columnDeleteSchema,
992
- }, removeColumnFromTable);
993
- // ─── CREATE RELATION ───
994
- server.tool('create_relation', [
995
- 'Create a relation between two tables (many-to-one, one-to-many, one-to-one, many-to-many).',
996
- 'sourceTableId and targetTableId may be table ids, exact table names, or aliases; MCP resolves them from metadata before mutation.',
997
- '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.',
998
- '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.',
999
- 'Run sequentially — DB migration locks per operation.',
1000
- ].join(' '), {
1001
- ...relationCreateSchema,
1002
- }, appendRelationToTable);
1003
- // ─── DELETE RELATION ───
1004
- server.tool('delete_relation', [
1005
- 'Delete a relation from a table via PATCH /enfyra_table/{tableId}.',
1006
- 'Fetches all relations, removes the target, and PATCHes the table.',
1007
- 'Drops FK columns and junction tables (for many-to-many).',
1008
- ].join(' '), {
1009
- ...relationDeleteSchema,
1010
- }, removeRelationFromTable);
1105
+ });
1011
1106
  }
1012
1107
  //# sourceMappingURL=table-tools.js.map