@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.
- package/dist/lib/mcp-examples.js +161 -264
- package/dist/lib/mcp-examples.js.map +1 -1
- package/dist/lib/mcp-instructions.js +4 -4
- package/dist/lib/mcp-instructions.js.map +1 -1
- package/dist/lib/mutation-guards.js +3 -3
- package/dist/lib/mutation-guards.js.map +1 -1
- package/dist/lib/required-knowledge.js +5 -4
- package/dist/lib/required-knowledge.js.map +1 -1
- package/dist/lib/table-tools.js +382 -287
- package/dist/lib/table-tools.js.map +1 -1
- package/dist/lib/tool-routing.js +18 -18
- package/dist/lib/tool-routing.js.map +1 -1
- package/dist/mcp-server-entry.js +150 -77
- package/dist/mcp-server-entry.js.map +1 -1
- package/package.json +1 -1
package/dist/lib/table-tools.js
CHANGED
|
@@ -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(`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: '
|
|
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
|
|
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
|
-
// ───
|
|
714
|
-
server.tool('
|
|
715
|
-
'Create a
|
|
716
|
-
'
|
|
717
|
-
'
|
|
718
|
-
'
|
|
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
|
-
|
|
732
|
-
|
|
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 ({
|
|
915
|
+
}, async ({ items, maxItems, globalRulesAckKey }) => {
|
|
740
916
|
assertGlobalRulesAck(globalRulesAckKey);
|
|
741
|
-
const
|
|
742
|
-
|
|
743
|
-
const
|
|
744
|
-
const
|
|
745
|
-
const
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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: '
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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 ({
|
|
960
|
+
}, async ({ items, maxItems, globalRulesAckKey }) => {
|
|
842
961
|
assertGlobalRulesAck(globalRulesAckKey);
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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: '
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
// ───
|
|
878
|
-
server.tool('
|
|
879
|
-
'
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
const
|
|
888
|
-
|
|
889
|
-
|
|
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
|
|
903
|
-
|
|
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: '
|
|
907
|
-
|
|
908
|
-
|
|
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
|
-
// ───
|
|
912
|
-
server.tool('
|
|
913
|
-
'
|
|
914
|
-
'
|
|
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 ({
|
|
1067
|
+
}, async ({ items, maxItems, globalRulesAckKey }) => {
|
|
940
1068
|
assertGlobalRulesAck(globalRulesAckKey);
|
|
941
|
-
const
|
|
942
|
-
|
|
943
|
-
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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: '
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|