@enfyra/mcp-server 0.1.13 → 0.1.14
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/README.md +14 -1
- package/dist/index.d.ts +5 -0
- package/{src/index.mjs → dist/index.js} +8 -10
- package/dist/index.js.map +1 -0
- package/dist/lib/auth.d.ts +34 -0
- package/dist/lib/auth.js +161 -0
- package/dist/lib/auth.js.map +1 -0
- package/dist/lib/config-local.d.ts +1 -0
- package/dist/lib/config-local.js +719 -0
- package/dist/lib/config-local.js.map +1 -0
- package/dist/lib/fetch.d.ts +28 -0
- package/dist/lib/fetch.js +106 -0
- package/dist/lib/fetch.js.map +1 -0
- package/dist/lib/mcp-examples.d.ts +99 -0
- package/dist/lib/mcp-examples.js +2285 -0
- package/dist/lib/mcp-examples.js.map +1 -0
- package/dist/lib/mcp-instructions.d.ts +11 -0
- package/dist/lib/mcp-instructions.js +78 -0
- package/dist/lib/mcp-instructions.js.map +1 -0
- package/dist/lib/mutation-guards.d.ts +33 -0
- package/dist/lib/mutation-guards.js +106 -0
- package/dist/lib/mutation-guards.js.map +1 -0
- package/dist/lib/platform-operation-tools.d.ts +12 -0
- package/dist/lib/platform-operation-tools.js +2304 -0
- package/dist/lib/platform-operation-tools.js.map +1 -0
- package/dist/lib/required-knowledge.d.ts +32 -0
- package/dist/lib/required-knowledge.js +181 -0
- package/dist/lib/required-knowledge.js.map +1 -0
- package/dist/lib/response-format.d.ts +7 -0
- package/dist/lib/response-format.js +179 -0
- package/dist/lib/response-format.js.map +1 -0
- package/dist/lib/route-guards.d.ts +1 -0
- package/dist/lib/route-guards.js +19 -0
- package/dist/lib/route-guards.js.map +1 -0
- package/dist/lib/route-permission-tools.d.ts +91 -0
- package/dist/lib/route-permission-tools.js +151 -0
- package/dist/lib/route-permission-tools.js.map +1 -0
- package/dist/lib/source-artifacts.d.ts +27 -0
- package/dist/lib/source-artifacts.js +82 -0
- package/dist/lib/source-artifacts.js.map +1 -0
- package/dist/lib/table-tools.d.ts +62 -0
- package/dist/lib/table-tools.js +774 -0
- package/dist/lib/table-tools.js.map +1 -0
- package/dist/lib/tool-routing.d.ts +297 -0
- package/dist/lib/tool-routing.js +585 -0
- package/dist/lib/tool-routing.js.map +1 -0
- package/dist/lib/types.d.ts +17 -0
- package/dist/lib/types.js +2 -0
- package/dist/lib/types.js.map +1 -0
- package/dist/mcp-server-entry.d.ts +4 -0
- package/dist/mcp-server-entry.js +2785 -0
- package/dist/mcp-server-entry.js.map +1 -0
- package/package.json +16 -9
- package/src/lib/auth.js +0 -179
- package/src/lib/config-local.mjs +0 -718
- package/src/lib/fetch.js +0 -111
- package/src/lib/mcp-examples.js +0 -2289
- package/src/lib/mcp-instructions.js +0 -80
- package/src/lib/mutation-guards.js +0 -118
- package/src/lib/platform-operation-tools.js +0 -2616
- package/src/lib/required-knowledge.js +0 -188
- package/src/lib/response-format.js +0 -187
- package/src/lib/route-guards.js +0 -24
- package/src/lib/route-permission-tools.js +0 -160
- package/src/lib/source-artifacts.js +0 -82
- package/src/lib/table-tools.js +0 -907
- package/src/lib/tool-routing.js +0 -589
- package/src/mcp-server-entry.mjs +0 -3177
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table & Column tools for Enfyra MCP Server
|
|
3
|
+
*/
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { fetchAPI } from './fetch.js';
|
|
6
|
+
import { jsonContent } from './response-format.js';
|
|
7
|
+
import { assertGlobalRulesAck, globalRulesAckParam } from './required-knowledge.js';
|
|
8
|
+
let schemaQueue = Promise.resolve();
|
|
9
|
+
function withSchemaQueue(operation) {
|
|
10
|
+
const run = schemaQueue.then(operation, operation);
|
|
11
|
+
schemaQueue = run.catch(() => { });
|
|
12
|
+
return run;
|
|
13
|
+
}
|
|
14
|
+
const FORBIDDEN_RELATION_KEYS = [
|
|
15
|
+
'fkCol',
|
|
16
|
+
'fkColumn',
|
|
17
|
+
'foreignKeyColumn',
|
|
18
|
+
'referencedColumn',
|
|
19
|
+
'constraintName',
|
|
20
|
+
'sourceColumn',
|
|
21
|
+
'targetColumn',
|
|
22
|
+
'junctionTableName',
|
|
23
|
+
'junctionSourceColumn',
|
|
24
|
+
'junctionTargetColumn',
|
|
25
|
+
];
|
|
26
|
+
export function normalizeTablesFromMetadata(metadata) {
|
|
27
|
+
const tablesSource = metadata?.data?.tables || metadata?.tables || metadata?.data || [];
|
|
28
|
+
return Array.isArray(tablesSource)
|
|
29
|
+
? tablesSource
|
|
30
|
+
: Object.values(tablesSource || {});
|
|
31
|
+
}
|
|
32
|
+
export function resolveTableFromMetadata(metadata, tableId) {
|
|
33
|
+
return normalizeTablesFromMetadata(metadata)
|
|
34
|
+
.find((table) => String(getId(table)) === String(tableId)) || null;
|
|
35
|
+
}
|
|
36
|
+
export function resolveTableFromMetadataByName(metadata, tableName) {
|
|
37
|
+
if (!tableName)
|
|
38
|
+
return null;
|
|
39
|
+
return normalizeTablesFromMetadata(metadata)
|
|
40
|
+
.find((table) => table?.name === tableName || table?.alias === tableName) || null;
|
|
41
|
+
}
|
|
42
|
+
export function resolveTableIdentifierFromMetadata(metadata, tableRef, label = 'table') {
|
|
43
|
+
const resolvedTable = normalizeTablesFromMetadata(metadata)
|
|
44
|
+
.find((table) => (String(getId(table)) === String(tableRef) ||
|
|
45
|
+
table?.name === tableRef ||
|
|
46
|
+
table?.alias === tableRef));
|
|
47
|
+
if (!resolvedTable) {
|
|
48
|
+
throw new Error(`${label} "${tableRef}" was not found in metadata. Pass an existing table id, name, or alias from get_all_tables/inspect_table.`);
|
|
49
|
+
}
|
|
50
|
+
return getId(resolvedTable);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Helper: fetch table with full columns and relations.
|
|
54
|
+
* Dynamic enfyra_table relation fields can be paginated/truncated, so schema
|
|
55
|
+
* cascade tools must use /metadata as the complete source of columns/relations.
|
|
56
|
+
*/
|
|
57
|
+
export async function fetchTableWithDetails(ENFYRA_API_URL, tableId) {
|
|
58
|
+
const filter = encodeURIComponent(JSON.stringify({ id: { _eq: tableId } }));
|
|
59
|
+
const [tableResult, metadata] = await Promise.all([
|
|
60
|
+
fetchAPI(ENFYRA_API_URL, `/enfyra_table?filter=${filter}&limit=1&fields=*`),
|
|
61
|
+
fetchAPI(ENFYRA_API_URL, '/metadata'),
|
|
62
|
+
]);
|
|
63
|
+
const tableData = tableResult?.data?.[0] || tableResult?.[0] || null;
|
|
64
|
+
const metadataTable = resolveTableFromMetadata(metadata, tableId) ||
|
|
65
|
+
resolveTableFromMetadataByName(metadata, tableData?.name);
|
|
66
|
+
if (!metadataTable) {
|
|
67
|
+
throw new Error(`Full metadata for table ${tableId} was not found; refusing schema cascade patch.`);
|
|
68
|
+
}
|
|
69
|
+
if (!Array.isArray(metadataTable.columns)) {
|
|
70
|
+
throw new Error(`Full metadata for table ${tableId} did not include columns; refusing schema cascade patch.`);
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
...(tableData || metadataTable),
|
|
74
|
+
columns: metadataTable.columns,
|
|
75
|
+
relations: Array.isArray(metadataTable.relations) ? metadataTable.relations : [],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* PATCH enfyra_table with auto-confirm for schema changes.
|
|
80
|
+
* First PATCH returns preview + requiredConfirmHash; this helper
|
|
81
|
+
* automatically resends with ?schemaConfirmHash= to apply.
|
|
82
|
+
*/
|
|
83
|
+
async function patchTableAutoConfirm(ENFYRA_API_URL, tableId, body) {
|
|
84
|
+
const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_table/${tableId}`, {
|
|
85
|
+
method: 'PATCH',
|
|
86
|
+
body: JSON.stringify(body),
|
|
87
|
+
});
|
|
88
|
+
const preview = Array.isArray(result?.data) ? result.data[0] : result?.data;
|
|
89
|
+
if (preview?._preview && preview?.requiredConfirmHash) {
|
|
90
|
+
return fetchAPI(ENFYRA_API_URL, `/enfyra_table/${tableId}?schemaConfirmHash=${preview.requiredConfirmHash}`, {
|
|
91
|
+
method: 'PATCH',
|
|
92
|
+
body: JSON.stringify(body),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
function parseJsonArrayParam(name, value) {
|
|
98
|
+
if (!value)
|
|
99
|
+
return [];
|
|
100
|
+
const parsed = JSON.parse(value);
|
|
101
|
+
if (!Array.isArray(parsed)) {
|
|
102
|
+
throw new Error(`${name} must be a JSON array.`);
|
|
103
|
+
}
|
|
104
|
+
return parsed;
|
|
105
|
+
}
|
|
106
|
+
function normalizeConstraintGroups(name, groups) {
|
|
107
|
+
return groups.map((group, index) => {
|
|
108
|
+
const value = Array.isArray(group) ? group : group?.value;
|
|
109
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string' || !item.trim())) {
|
|
110
|
+
throw new Error(`${name}[${index}] must be a non-empty string array or { "value": [...] }.`);
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function parseConstraintGroupsParam(name, value) {
|
|
116
|
+
return normalizeConstraintGroups(name, parseJsonArrayParam(name, value));
|
|
117
|
+
}
|
|
118
|
+
function normalizeConstraintGroupsValue(name, value) {
|
|
119
|
+
if (value == null)
|
|
120
|
+
return [];
|
|
121
|
+
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
|
122
|
+
if (!Array.isArray(parsed)) {
|
|
123
|
+
throw new Error(`${name} must be a JSON array.`);
|
|
124
|
+
}
|
|
125
|
+
return normalizeConstraintGroups(name, parsed);
|
|
126
|
+
}
|
|
127
|
+
export function assertIndexesDoNotReferenceUniqueFields(indexes, uniques) {
|
|
128
|
+
const uniqueFields = new Set(uniques.flat());
|
|
129
|
+
const conflicts = indexes
|
|
130
|
+
.map((group) => ({
|
|
131
|
+
index: group,
|
|
132
|
+
uniqueFields: group.filter((field) => uniqueFields.has(field)),
|
|
133
|
+
}))
|
|
134
|
+
.filter((conflict) => conflict.uniqueFields.length > 0);
|
|
135
|
+
if (conflicts.length > 0) {
|
|
136
|
+
const groups = conflicts
|
|
137
|
+
.map((conflict) => `${JSON.stringify(conflict.index)} uses unique field(s) ${JSON.stringify(conflict.uniqueFields)}`)
|
|
138
|
+
.join('; ');
|
|
139
|
+
throw new Error(`Invalid schema constraints: indexes must not include fields that are already unique. Conflict(s): ${groups}. Unique constraints already create indexed lookups; remove unique fields from indexes and keep those fields only in uniques.`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function normalizeRelationForTablePatch(relation) {
|
|
143
|
+
for (const key of FORBIDDEN_RELATION_KEYS) {
|
|
144
|
+
if (Object.prototype.hasOwnProperty.call(relation, key)) {
|
|
145
|
+
throw new Error(`Relation schema must not include physical column field "${key}". Use propertyName/targetTable only; Enfyra derives FK and junction columns.`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const { sourceTable, targetTable, targetTableId, mappedBy, fkCol, fkColumn, foreignKeyColumn, sourceColumn, targetColumn, junctionSourceColumn, junctionTargetColumn, ...rest } = relation;
|
|
149
|
+
const normalized = { ...rest };
|
|
150
|
+
const resolvedTargetTable = targetTableId ??
|
|
151
|
+
(targetTable && typeof targetTable === 'object'
|
|
152
|
+
? targetTable.id ?? targetTable._id ?? targetTable
|
|
153
|
+
: targetTable);
|
|
154
|
+
if (resolvedTargetTable !== undefined && resolvedTargetTable !== null) {
|
|
155
|
+
normalized.targetTable = resolvedTargetTable;
|
|
156
|
+
}
|
|
157
|
+
if (mappedBy !== undefined && mappedBy !== null && mappedBy !== '') {
|
|
158
|
+
normalized.mappedBy = typeof mappedBy === 'object'
|
|
159
|
+
? mappedBy.propertyName ?? mappedBy.name ?? mappedBy.id ?? mappedBy._id
|
|
160
|
+
: mappedBy;
|
|
161
|
+
}
|
|
162
|
+
return normalized;
|
|
163
|
+
}
|
|
164
|
+
function assertNoForbiddenRelationKeys(args) {
|
|
165
|
+
for (const key of FORBIDDEN_RELATION_KEYS) {
|
|
166
|
+
if (Object.prototype.hasOwnProperty.call(args, key)) {
|
|
167
|
+
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.`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
export function sanitizeExistingRelationForTablePatch(relation) {
|
|
172
|
+
const { fkCol, fkColumn, foreignKeyColumn, referencedColumn, constraintName, sourceColumn, targetColumn, junctionTableName, junctionSourceColumn, junctionTargetColumn, ...rest } = relation;
|
|
173
|
+
return normalizeRelationForTablePatch(rest);
|
|
174
|
+
}
|
|
175
|
+
export function resolveRelationTargetsFromMetadata(metadata, relations) {
|
|
176
|
+
return relations.map((relation) => {
|
|
177
|
+
const targetTable = relation.targetTable;
|
|
178
|
+
if (typeof targetTable !== 'string' || !targetTable.trim())
|
|
179
|
+
return relation;
|
|
180
|
+
const resolvedTable = resolveTableFromMetadataByName(metadata, targetTable);
|
|
181
|
+
if (!resolvedTable)
|
|
182
|
+
return relation;
|
|
183
|
+
return { ...relation, targetTable: getId(resolvedTable) };
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
function getId(record) {
|
|
187
|
+
return record?.id ?? record?._id ?? null;
|
|
188
|
+
}
|
|
189
|
+
function normalizeColumnForTablePatch(column) {
|
|
190
|
+
const { table, ...rest } = column;
|
|
191
|
+
return rest;
|
|
192
|
+
}
|
|
193
|
+
function getPatchableColumns(columns = []) {
|
|
194
|
+
return (columns || [])
|
|
195
|
+
.filter((column) => getId(column) !== null)
|
|
196
|
+
.map(normalizeColumnForTablePatch);
|
|
197
|
+
}
|
|
198
|
+
function getMissingIds(beforeIds, afterIds, excludedIds = []) {
|
|
199
|
+
const afterSet = new Set(afterIds.map(String));
|
|
200
|
+
const excludedSet = new Set(excludedIds.map(String));
|
|
201
|
+
return beforeIds
|
|
202
|
+
.map(String)
|
|
203
|
+
.filter((id) => !excludedSet.has(id) && !afterSet.has(id));
|
|
204
|
+
}
|
|
205
|
+
async function verifyColumnCascade(ENFYRA_API_URL, tableId, beforeIds, { action, columnId, columnName, }) {
|
|
206
|
+
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
|
|
207
|
+
const afterColumns = getPatchableColumns(tableData.columns);
|
|
208
|
+
const afterIds = afterColumns.map((column) => String(getId(column)));
|
|
209
|
+
const excludedIds = action === 'delete' ? [columnId] : [];
|
|
210
|
+
const missingIds = getMissingIds(beforeIds, afterIds, excludedIds);
|
|
211
|
+
if (missingIds.length > 0) {
|
|
212
|
+
throw new Error(`Schema cascade verification failed: unrelated column ids disappeared: ${missingIds.join(', ')}`);
|
|
213
|
+
}
|
|
214
|
+
if (action === 'create' && !afterColumns.some((column) => column.name === columnName)) {
|
|
215
|
+
throw new Error(`Schema cascade verification failed: column "${columnName}" was not found after create.`);
|
|
216
|
+
}
|
|
217
|
+
if (action === 'delete' && afterIds.includes(String(columnId))) {
|
|
218
|
+
throw new Error(`Schema cascade verification failed: column ${columnId} still exists after delete.`);
|
|
219
|
+
}
|
|
220
|
+
if (action === 'update' && !afterIds.includes(String(columnId))) {
|
|
221
|
+
throw new Error(`Schema cascade verification failed: column ${columnId} was not found after update.`);
|
|
222
|
+
}
|
|
223
|
+
return afterColumns;
|
|
224
|
+
}
|
|
225
|
+
async function verifyRelationCascade(ENFYRA_API_URL, tableId, beforeIds, { action, relationId, propertyName, }) {
|
|
226
|
+
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
|
|
227
|
+
const afterRelations = (tableData.relations || []).map(sanitizeExistingRelationForTablePatch);
|
|
228
|
+
const afterIds = afterRelations.map((relation) => String(getId(relation))).filter((id) => id !== 'null');
|
|
229
|
+
const excludedIds = action === 'delete' ? [relationId] : [];
|
|
230
|
+
const missingIds = getMissingIds(beforeIds, afterIds, excludedIds);
|
|
231
|
+
if (missingIds.length > 0) {
|
|
232
|
+
throw new Error(`Schema cascade verification failed: unrelated relation ids disappeared: ${missingIds.join(', ')}`);
|
|
233
|
+
}
|
|
234
|
+
if (action === 'create' && !afterRelations.some((relation) => relation.propertyName === propertyName)) {
|
|
235
|
+
throw new Error(`Schema cascade verification failed: relation "${propertyName}" was not found after create.`);
|
|
236
|
+
}
|
|
237
|
+
if (action === 'delete' && afterIds.includes(String(relationId))) {
|
|
238
|
+
throw new Error(`Schema cascade verification failed: relation ${relationId} still exists after delete.`);
|
|
239
|
+
}
|
|
240
|
+
return afterRelations;
|
|
241
|
+
}
|
|
242
|
+
export function buildColumnDefinition({ name, type, isNullable, isUnique, isPublished, isUpdatable, isEncrypted, isPrimary, isGenerated, isSystem, defaultValue, description, options, }) {
|
|
243
|
+
const column = {
|
|
244
|
+
name,
|
|
245
|
+
type,
|
|
246
|
+
isNullable: isNullable ?? true,
|
|
247
|
+
isPrimary: isPrimary ?? false,
|
|
248
|
+
isGenerated: isGenerated ?? false,
|
|
249
|
+
isSystem: isSystem ?? false,
|
|
250
|
+
isPublished: isPublished ?? true,
|
|
251
|
+
isUpdatable: isUpdatable ?? true,
|
|
252
|
+
isEncrypted: isEncrypted ?? false,
|
|
253
|
+
};
|
|
254
|
+
if (isUnique !== undefined)
|
|
255
|
+
column.isUnique = isUnique;
|
|
256
|
+
if (defaultValue !== undefined)
|
|
257
|
+
column.defaultValue = defaultValue;
|
|
258
|
+
if (description !== undefined)
|
|
259
|
+
column.description = description;
|
|
260
|
+
if (options !== undefined)
|
|
261
|
+
column.options = JSON.parse(options);
|
|
262
|
+
return column;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Register table tools with MCP server
|
|
266
|
+
*/
|
|
267
|
+
export function registerTableTools(server, ENFYRA_API_URL) {
|
|
268
|
+
const apiBase = ENFYRA_API_URL.replace(/\/$/, '');
|
|
269
|
+
async function appendColumnToTable(args) {
|
|
270
|
+
assertGlobalRulesAck(args.globalRulesAckKey);
|
|
271
|
+
return withSchemaQueue(async () => {
|
|
272
|
+
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, args.tableId);
|
|
273
|
+
if (!tableData) {
|
|
274
|
+
throw new Error(`Table with ID ${args.tableId} not found.`);
|
|
275
|
+
}
|
|
276
|
+
const existingColumns = getPatchableColumns(tableData.columns);
|
|
277
|
+
const beforeIds = existingColumns.map((column) => String(getId(column)));
|
|
278
|
+
const newCol = buildColumnDefinition(args);
|
|
279
|
+
const result = await patchTableAutoConfirm(ENFYRA_API_URL, args.tableId, { columns: [...existingColumns, newCol] });
|
|
280
|
+
await verifyColumnCascade(ENFYRA_API_URL, args.tableId, beforeIds, {
|
|
281
|
+
action: 'create',
|
|
282
|
+
columnName: args.name,
|
|
283
|
+
});
|
|
284
|
+
return jsonContent({
|
|
285
|
+
action: 'column_created',
|
|
286
|
+
tableId: args.tableId,
|
|
287
|
+
columnName: args.name,
|
|
288
|
+
result,
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
async function appendRelationToTable(args) {
|
|
293
|
+
assertGlobalRulesAck(args.globalRulesAckKey);
|
|
294
|
+
return withSchemaQueue(async () => {
|
|
295
|
+
assertNoForbiddenRelationKeys(args);
|
|
296
|
+
const { sourceTableId, targetTableId, type, propertyName, inversePropertyName, mappedBy, isNullable, onDelete, description } = args;
|
|
297
|
+
const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
|
|
298
|
+
const resolvedSourceTableId = resolveTableIdentifierFromMetadata(metadata, sourceTableId, 'sourceTableId');
|
|
299
|
+
const resolvedTargetTableId = resolveTableIdentifierFromMetadata(metadata, targetTableId, 'targetTableId');
|
|
300
|
+
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, resolvedSourceTableId);
|
|
301
|
+
if (!tableData) {
|
|
302
|
+
throw new Error(`Table ${sourceTableId} not found.`);
|
|
303
|
+
}
|
|
304
|
+
const existingRelations = (tableData.relations || []).map(sanitizeExistingRelationForTablePatch);
|
|
305
|
+
const beforeIds = existingRelations.map((relation) => String(getId(relation))).filter((id) => id !== 'null');
|
|
306
|
+
const newRelation = { targetTable: resolvedTargetTableId, type, propertyName };
|
|
307
|
+
if (inversePropertyName !== undefined)
|
|
308
|
+
newRelation.inversePropertyName = inversePropertyName || null;
|
|
309
|
+
if (mappedBy !== undefined)
|
|
310
|
+
newRelation.mappedBy = mappedBy;
|
|
311
|
+
if (isNullable !== undefined)
|
|
312
|
+
newRelation.isNullable = isNullable;
|
|
313
|
+
if (onDelete !== undefined)
|
|
314
|
+
newRelation.onDelete = onDelete;
|
|
315
|
+
if (description !== undefined)
|
|
316
|
+
newRelation.description = description;
|
|
317
|
+
const result = await patchTableAutoConfirm(ENFYRA_API_URL, resolvedSourceTableId, { relations: [...existingRelations, newRelation] });
|
|
318
|
+
await verifyRelationCascade(ENFYRA_API_URL, resolvedSourceTableId, beforeIds, {
|
|
319
|
+
action: 'create',
|
|
320
|
+
propertyName,
|
|
321
|
+
});
|
|
322
|
+
return jsonContent({
|
|
323
|
+
action: 'relation_created',
|
|
324
|
+
relation: { propertyName, type, sourceTableId: resolvedSourceTableId, targetTableId: resolvedTargetTableId },
|
|
325
|
+
result,
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
async function removeColumnFromTable({ tableId, columnId, confirm, globalRulesAckKey }) {
|
|
330
|
+
return withSchemaQueue(async () => {
|
|
331
|
+
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
|
|
332
|
+
if (!tableData) {
|
|
333
|
+
throw new Error(`Table with ID ${tableId} not found.`);
|
|
334
|
+
}
|
|
335
|
+
const existingColumns = getPatchableColumns(tableData.columns);
|
|
336
|
+
const beforeIds = existingColumns.map((column) => String(getId(column)));
|
|
337
|
+
if (!beforeIds.includes(String(columnId))) {
|
|
338
|
+
throw new Error(`Column ${columnId} was not found on table ${tableId}; refusing schema cascade patch.`);
|
|
339
|
+
}
|
|
340
|
+
if (!confirm) {
|
|
341
|
+
const target = existingColumns.find((column) => String(getId(column)) === String(columnId));
|
|
342
|
+
return {
|
|
343
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
344
|
+
action: 'delete_column_preview',
|
|
345
|
+
tableId,
|
|
346
|
+
columnId,
|
|
347
|
+
targetColumn: target,
|
|
348
|
+
preservedColumnIds: beforeIds.filter((id) => id !== String(columnId)),
|
|
349
|
+
destructive: true,
|
|
350
|
+
next: 'Call delete_column again with confirm=true to drop the physical column and metadata.',
|
|
351
|
+
}, null, 2) }],
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
assertGlobalRulesAck(globalRulesAckKey);
|
|
355
|
+
const columns = existingColumns
|
|
356
|
+
.filter(col => String(getId(col)) !== String(columnId));
|
|
357
|
+
const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, { columns });
|
|
358
|
+
await verifyColumnCascade(ENFYRA_API_URL, tableId, beforeIds, {
|
|
359
|
+
action: 'delete',
|
|
360
|
+
columnId,
|
|
361
|
+
});
|
|
362
|
+
return jsonContent({
|
|
363
|
+
action: 'column_deleted',
|
|
364
|
+
tableId,
|
|
365
|
+
columnId,
|
|
366
|
+
result,
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
async function removeRelationFromTable({ tableId, relationId, confirm, globalRulesAckKey }) {
|
|
371
|
+
return withSchemaQueue(async () => {
|
|
372
|
+
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
|
|
373
|
+
if (!tableData) {
|
|
374
|
+
throw new Error(`Table with ID ${tableId} not found.`);
|
|
375
|
+
}
|
|
376
|
+
const existingRelations = (tableData.relations || []).map(sanitizeExistingRelationForTablePatch);
|
|
377
|
+
const beforeIds = existingRelations.map((relation) => String(getId(relation))).filter((id) => id !== 'null');
|
|
378
|
+
if (!beforeIds.includes(String(relationId))) {
|
|
379
|
+
throw new Error(`Relation ${relationId} was not found on table ${tableId}; refusing schema cascade patch.`);
|
|
380
|
+
}
|
|
381
|
+
if (!confirm) {
|
|
382
|
+
const target = existingRelations.find((relation) => String(getId(relation)) === String(relationId));
|
|
383
|
+
return {
|
|
384
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
385
|
+
action: 'delete_relation_preview',
|
|
386
|
+
tableId,
|
|
387
|
+
relationId,
|
|
388
|
+
targetRelation: target,
|
|
389
|
+
preservedRelationIds: beforeIds.filter((id) => id !== String(relationId)),
|
|
390
|
+
destructive: true,
|
|
391
|
+
next: 'Call delete_relation again with confirm=true to drop relation metadata and any derived FK/junction structures.',
|
|
392
|
+
}, null, 2) }],
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
assertGlobalRulesAck(globalRulesAckKey);
|
|
396
|
+
const relations = existingRelations
|
|
397
|
+
.filter(rel => String(getId(rel)) !== String(relationId));
|
|
398
|
+
const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, { relations });
|
|
399
|
+
await verifyRelationCascade(ENFYRA_API_URL, tableId, beforeIds, {
|
|
400
|
+
action: 'delete',
|
|
401
|
+
relationId,
|
|
402
|
+
});
|
|
403
|
+
return jsonContent({
|
|
404
|
+
action: 'relation_deleted',
|
|
405
|
+
tableId,
|
|
406
|
+
relationId,
|
|
407
|
+
result,
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
const columnCreateSchema = {
|
|
412
|
+
tableId: z.string().describe('Table definition ID (from get_all_tables or create_table).'),
|
|
413
|
+
name: z.string().describe('Column name (e.g., "title", "webhook_secret"). Lowercase with underscores.'),
|
|
414
|
+
type: z.string().describe('Column type: varchar, int, text, boolean, datetime, json, decimal, timestamp, uuid, bigint, float, longtext, richtext, simple-json, code, enum, array-select, date.'),
|
|
415
|
+
isNullable: z.boolean().optional().default(true).describe('Set to false if column cannot be null.'),
|
|
416
|
+
isUnique: z.boolean().optional().default(false).describe('Set to true for unique constraint.'),
|
|
417
|
+
isPublished: z.boolean().optional().describe('Set column visibility baseline. Use false for secrets and internal fields.'),
|
|
418
|
+
isUpdatable: z.boolean().optional().describe('Set false for immutable fields that cannot be updated after creation. Independent from isEncrypted.'),
|
|
419
|
+
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.'),
|
|
420
|
+
isPrimary: z.boolean().optional().describe('Set true only for primary key columns; normally only create_table auto id uses this.'),
|
|
421
|
+
isGenerated: z.boolean().optional().describe('Set true only for generated columns such as auto id.'),
|
|
422
|
+
isSystem: z.boolean().optional().describe('Set true only for system-managed columns. Avoid for normal app fields.'),
|
|
423
|
+
defaultValue: z.string().optional().describe('Default value as JSON string or backend-supported literal.'),
|
|
424
|
+
description: z.string().optional().describe('Column description.'),
|
|
425
|
+
options: z.string().optional().describe('Column options as JSON string (e.g., enum values).'),
|
|
426
|
+
globalRulesAckKey: globalRulesAckParam(z),
|
|
427
|
+
};
|
|
428
|
+
const relationCreateSchema = {
|
|
429
|
+
sourceTableId: z.string().describe('Source table id, exact table name, or alias. For many-to-one, this is the table that owns the relation property.'),
|
|
430
|
+
targetTableId: z.string().describe('Target table id, exact table name, or alias. MCP resolves names/aliases to ids before mutation.'),
|
|
431
|
+
type: z.enum(['many-to-one', 'one-to-many', 'one-to-one', 'many-to-many']).describe('Relation type.'),
|
|
432
|
+
propertyName: z.string().describe('Property name on source table (e.g., "customer", "items").'),
|
|
433
|
+
inversePropertyName: z.string().optional().describe('Property name on target table for bidirectional relation (e.g., "orders"). Omit unless a concrete response, UI, deep query, aggregate sort/count, or parent-to-child traversal will use the reverse field. Do not add inverses merely because a parent table exists.'),
|
|
434
|
+
mappedBy: z.string().optional().describe('Mapped-by property for inverse relation shapes when required by the backend. Do not use physical FK names.'),
|
|
435
|
+
isNullable: z.boolean().optional().default(true).describe('Whether the relation is nullable.'),
|
|
436
|
+
onDelete: z.enum(['CASCADE', 'SET NULL', 'RESTRICT']).optional().default('SET NULL').describe('On delete behavior.'),
|
|
437
|
+
description: z.string().optional().describe('Relation description.'),
|
|
438
|
+
fkCol: z.never().optional().describe('Forbidden. Use propertyName only; Enfyra derives FK columns.'),
|
|
439
|
+
fkColumn: z.never().optional().describe('Forbidden. Use propertyName only; Enfyra derives FK columns.'),
|
|
440
|
+
foreignKeyColumn: z.never().optional().describe('Forbidden. Use propertyName only; Enfyra derives FK columns.'),
|
|
441
|
+
sourceColumn: z.never().optional().describe('Forbidden. Use propertyName only; Enfyra derives FK columns.'),
|
|
442
|
+
targetColumn: z.never().optional().describe('Forbidden. Use propertyName only; Enfyra derives FK columns.'),
|
|
443
|
+
junctionSourceColumn: z.never().optional().describe('Forbidden. Use relation property names only; Enfyra derives junction columns.'),
|
|
444
|
+
junctionTargetColumn: z.never().optional().describe('Forbidden. Use relation property names only; Enfyra derives junction columns.'),
|
|
445
|
+
globalRulesAckKey: globalRulesAckParam(z),
|
|
446
|
+
};
|
|
447
|
+
const columnDeleteSchema = {
|
|
448
|
+
tableId: z.string().describe('Table definition ID.'),
|
|
449
|
+
columnId: z.string().describe('Column definition ID to delete.'),
|
|
450
|
+
confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
|
|
451
|
+
globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when confirm=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
|
|
452
|
+
};
|
|
453
|
+
const relationDeleteSchema = {
|
|
454
|
+
tableId: z.string().describe('Table definition ID (source table of the relation).'),
|
|
455
|
+
relationId: z.string().describe('Relation definition ID to delete.'),
|
|
456
|
+
confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
|
|
457
|
+
globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when confirm=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
|
|
458
|
+
};
|
|
459
|
+
// ─── READ ───
|
|
460
|
+
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.', {
|
|
461
|
+
limit: z.number().int().positive().optional().describe('Maximum tables returned after search. Required unless all=true.'),
|
|
462
|
+
all: z.boolean().optional().describe('Return all matched tables. Use this when a complete table list is required.'),
|
|
463
|
+
search: z.string().optional().describe('Optional table name, alias, or description substring filter.'),
|
|
464
|
+
}, async ({ limit, all, search }) => {
|
|
465
|
+
if (!all && limit === undefined) {
|
|
466
|
+
throw new Error('get_all_tables requires either limit or all=true. Do not invent arbitrary limits for complete table lists; use all=true.');
|
|
467
|
+
}
|
|
468
|
+
const metadata = await fetchAPI(ENFYRA_API_URL, '/metadata');
|
|
469
|
+
const needle = search?.trim().toLowerCase();
|
|
470
|
+
const tables = normalizeTablesFromMetadata(metadata)
|
|
471
|
+
.map((table) => ({
|
|
472
|
+
id: getId(table),
|
|
473
|
+
name: table.name ?? null,
|
|
474
|
+
alias: table.alias ?? null,
|
|
475
|
+
description: table.description ?? null,
|
|
476
|
+
isSingleRecord: table.isSingleRecord ?? null,
|
|
477
|
+
columnCount: Array.isArray(table.columns) ? table.columns.length : null,
|
|
478
|
+
relationCount: Array.isArray(table.relations) ? table.relations.length : null,
|
|
479
|
+
routeBacked: Boolean(table.route || table.routeId || table.path),
|
|
480
|
+
}))
|
|
481
|
+
.filter((table) => {
|
|
482
|
+
if (!needle)
|
|
483
|
+
return true;
|
|
484
|
+
return [table.name, table.alias, table.description]
|
|
485
|
+
.some((value) => String(value || '').toLowerCase().includes(needle));
|
|
486
|
+
});
|
|
487
|
+
const returnedTables = all ? tables : tables.slice(0, limit);
|
|
488
|
+
return jsonContent({
|
|
489
|
+
action: 'get_all_tables',
|
|
490
|
+
totalTableCount: normalizeTablesFromMetadata(metadata).length,
|
|
491
|
+
matchedTableCount: tables.length,
|
|
492
|
+
returnedTableCount: returnedTables.length,
|
|
493
|
+
all: Boolean(all),
|
|
494
|
+
search: search || null,
|
|
495
|
+
tables: returnedTables,
|
|
496
|
+
detailHint: 'Use inspect_table with a table id/name for columns, relations, indexes, routes, permissions, and GraphQL state.',
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
// ─── CREATE TABLE ───
|
|
500
|
+
server.tool('create_table', [
|
|
501
|
+
'Create a new table definition with an auto-included `id` primary key column.',
|
|
502
|
+
'**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).',
|
|
503
|
+
'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.',
|
|
504
|
+
'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.',
|
|
505
|
+
'A field that appears in any `uniques` group must not appear in `indexes`; unique constraints already create indexed unique lookups.',
|
|
506
|
+
'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.',
|
|
507
|
+
'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.',
|
|
508
|
+
'Schema operations (create/update/delete table, add column) must run one at a time — migration locks DB; parallel calls will fail.',
|
|
509
|
+
'Enfyra auto-creates a default REST route at path `/<table_name>` (same segment as `name`, not alias).',
|
|
510
|
+
'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).',
|
|
511
|
+
'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`.',
|
|
512
|
+
'Set `isSingleRecord: true` directly in create_table for settings/config tables that should keep only one record.',
|
|
513
|
+
`Full URLs: ${apiBase}/<table_name> (example table post: ${apiBase}/post).`,
|
|
514
|
+
'GraphQL is enabled separately per table through `enfyra_graphql` or `update_table` with `graphqlEnabled`; it is not controlled by route availableMethods.',
|
|
515
|
+
'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.',
|
|
516
|
+
].join(' '), {
|
|
517
|
+
name: z.string().describe('Table name (e.g., "enfyra_user", "my_custom_table"). Must be unique, lowercase with underscores.'),
|
|
518
|
+
description: z.string().optional().describe('Description of what this table stores.'),
|
|
519
|
+
isSingleRecord: z.boolean().optional().describe('Set to true for single-record tables such as settings/config. This is passed directly to enfyra_table create.'),
|
|
520
|
+
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? }. Set isEncrypted=true for values encrypted at rest; set isUpdatable=false separately only when the field should be immutable. The `id` column is always auto-included. Example: [{"name":"title","type":"varchar"},{"name":"api_key","type":"varchar","isEncrypted":true,"isPublished":false}]'),
|
|
521
|
+
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"}]'),
|
|
522
|
+
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"]]'),
|
|
523
|
+
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"]]'),
|
|
524
|
+
globalRulesAckKey: globalRulesAckParam(z),
|
|
525
|
+
}, async ({ name, description, isSingleRecord, columns: columnsJson, relations: relationsJson, indexes: indexesJson, uniques: uniquesJson, globalRulesAckKey }) => withSchemaQueue(async () => {
|
|
526
|
+
assertGlobalRulesAck(globalRulesAckKey);
|
|
527
|
+
const idColumn = { name: 'id', type: 'int', isPrimary: true, isGenerated: true, isNullable: false };
|
|
528
|
+
const userColumns = parseJsonArrayParam('columns', columnsJson);
|
|
529
|
+
const parsedRelations = parseJsonArrayParam('relations', relationsJson).map(normalizeRelationForTablePatch);
|
|
530
|
+
const metadata = parsedRelations.length ? await fetchAPI(ENFYRA_API_URL, '/metadata') : null;
|
|
531
|
+
const userRelations = metadata
|
|
532
|
+
? resolveRelationTargetsFromMetadata(metadata, parsedRelations)
|
|
533
|
+
: parsedRelations;
|
|
534
|
+
const indexes = parseConstraintGroupsParam('indexes', indexesJson);
|
|
535
|
+
const uniques = parseConstraintGroupsParam('uniques', uniquesJson);
|
|
536
|
+
assertIndexesDoNotReferenceUniqueFields(indexes, uniques);
|
|
537
|
+
const body = { name, description, columns: [idColumn, ...userColumns], relations: userRelations };
|
|
538
|
+
if (isSingleRecord !== undefined)
|
|
539
|
+
body.isSingleRecord = isSingleRecord;
|
|
540
|
+
if (indexesJson !== undefined)
|
|
541
|
+
body.indexes = indexes;
|
|
542
|
+
if (uniquesJson !== undefined)
|
|
543
|
+
body.uniques = uniques;
|
|
544
|
+
const result = await fetchAPI(ENFYRA_API_URL, '/enfyra_table', {
|
|
545
|
+
method: 'POST',
|
|
546
|
+
body: JSON.stringify(body),
|
|
547
|
+
});
|
|
548
|
+
const createdTable = Array.isArray(result?.data) ? result.data[0] : result;
|
|
549
|
+
const createdTableId = createdTable?.id ?? createdTable?._id;
|
|
550
|
+
const base = ENFYRA_API_URL.replace(/\/$/, '');
|
|
551
|
+
const routePath = `/${name}`;
|
|
552
|
+
const restHint = [
|
|
553
|
+
`Auto route path: ${routePath} → full base for REST: ${base}${routePath}`,
|
|
554
|
+
`REST: GET+POST on ${routePath}; PATCH+DELETE on ${routePath}/:id only. No GET ${routePath}/:id.`,
|
|
555
|
+
].join('\n');
|
|
556
|
+
const colHint = userColumns.length
|
|
557
|
+
? `Table created with ${userColumns.length} column(s) + auto id.`
|
|
558
|
+
: `Table created. Use create_column to add columns (tableId: ${createdTableId}).`;
|
|
559
|
+
const relHint = userRelations.length
|
|
560
|
+
? `Relation(s) created in same call: ${userRelations.length}.`
|
|
561
|
+
: `No relations were included in this create_table call.`;
|
|
562
|
+
const constraintHint = [
|
|
563
|
+
indexes.length ? `Index group(s): ${indexes.length}.` : null,
|
|
564
|
+
uniques.length ? `Unique group(s): ${uniques.length}.` : null,
|
|
565
|
+
].filter(Boolean).join(' ');
|
|
566
|
+
return jsonContent({
|
|
567
|
+
action: 'table_created',
|
|
568
|
+
table: { id: createdTableId, name, routePath },
|
|
569
|
+
summary: {
|
|
570
|
+
columnCount: userColumns.length + 1,
|
|
571
|
+
createdColumnCount: userColumns.length,
|
|
572
|
+
relationCount: userRelations.length,
|
|
573
|
+
indexGroupCount: indexes.length,
|
|
574
|
+
uniqueGroupCount: uniques.length,
|
|
575
|
+
},
|
|
576
|
+
rest: {
|
|
577
|
+
base,
|
|
578
|
+
routePath,
|
|
579
|
+
operations: ['GET /<table>', 'POST /<table>', 'PATCH /<table>/:id', 'DELETE /<table>/:id'],
|
|
580
|
+
noGetById: true,
|
|
581
|
+
},
|
|
582
|
+
message: [colHint, relHint, constraintHint, restHint].filter(Boolean).join('\n'),
|
|
583
|
+
result,
|
|
584
|
+
});
|
|
585
|
+
}));
|
|
586
|
+
// ─── UPDATE TABLE ───
|
|
587
|
+
server.tool('update_table', [
|
|
588
|
+
'Update table properties: name (rename), alias, description, isSingleRecord, graphqlEnabled, indexes, and uniques.',
|
|
589
|
+
'Does NOT modify columns or relations — use create_column, update_column, delete_column, create_relation for those.',
|
|
590
|
+
'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"]].',
|
|
591
|
+
'A field that appears in any `uniques` group must not appear in `indexes`; unique constraints already create indexed unique lookups.',
|
|
592
|
+
'Run schema changes sequentially — migration locks DB per operation.',
|
|
593
|
+
].join(' '), {
|
|
594
|
+
tableId: z.string().describe('Table definition ID.'),
|
|
595
|
+
name: z.string().optional().describe('New table name (rename). Lowercase with underscores.'),
|
|
596
|
+
alias: z.string().optional().describe('New table alias.'),
|
|
597
|
+
description: z.string().optional().describe('New description.'),
|
|
598
|
+
isSingleRecord: z.boolean().optional().describe('Set to true for single-record table (e.g., settings/config).'),
|
|
599
|
+
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.'),
|
|
600
|
+
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.'),
|
|
601
|
+
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.'),
|
|
602
|
+
globalRulesAckKey: globalRulesAckParam(z),
|
|
603
|
+
}, async ({ tableId, name, alias, description, isSingleRecord, graphqlEnabled, indexes: indexesJson, uniques: uniquesJson, globalRulesAckKey }) => withSchemaQueue(async () => {
|
|
604
|
+
assertGlobalRulesAck(globalRulesAckKey);
|
|
605
|
+
const body = {};
|
|
606
|
+
if (name !== undefined)
|
|
607
|
+
body.name = name;
|
|
608
|
+
if (alias !== undefined)
|
|
609
|
+
body.alias = alias;
|
|
610
|
+
if (description !== undefined)
|
|
611
|
+
body.description = description;
|
|
612
|
+
if (isSingleRecord !== undefined)
|
|
613
|
+
body.isSingleRecord = isSingleRecord;
|
|
614
|
+
if (graphqlEnabled !== undefined)
|
|
615
|
+
body.graphqlEnabled = graphqlEnabled;
|
|
616
|
+
if (indexesJson !== undefined)
|
|
617
|
+
body.indexes = parseConstraintGroupsParam('indexes', indexesJson);
|
|
618
|
+
if (uniquesJson !== undefined)
|
|
619
|
+
body.uniques = parseConstraintGroupsParam('uniques', uniquesJson);
|
|
620
|
+
if (indexesJson !== undefined || uniquesJson !== undefined) {
|
|
621
|
+
let indexes = body.indexes;
|
|
622
|
+
let uniques = body.uniques;
|
|
623
|
+
if (indexes === undefined || uniques === undefined) {
|
|
624
|
+
const existing = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
|
|
625
|
+
if (indexes === undefined)
|
|
626
|
+
indexes = normalizeConstraintGroupsValue('indexes', existing.indexes);
|
|
627
|
+
if (uniques === undefined)
|
|
628
|
+
uniques = normalizeConstraintGroupsValue('uniques', existing.uniques);
|
|
629
|
+
}
|
|
630
|
+
assertIndexesDoNotReferenceUniqueFields(indexes ?? [], uniques ?? []);
|
|
631
|
+
}
|
|
632
|
+
const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, body);
|
|
633
|
+
return jsonContent({
|
|
634
|
+
action: 'table_updated',
|
|
635
|
+
tableId,
|
|
636
|
+
result,
|
|
637
|
+
});
|
|
638
|
+
}));
|
|
639
|
+
// ─── DELETE TABLE ───
|
|
640
|
+
server.tool('delete_table', [
|
|
641
|
+
'Delete a table and ALL associated data. This is DESTRUCTIVE and IRREVERSIBLE.',
|
|
642
|
+
'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.',
|
|
643
|
+
'Always confirm with the user before calling this tool.',
|
|
644
|
+
].join(' '), {
|
|
645
|
+
tableId: z.string().describe('Table definition ID to delete.'),
|
|
646
|
+
confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
|
|
647
|
+
globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when confirm=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
|
|
648
|
+
}, async ({ tableId, confirm, globalRulesAckKey }) => withSchemaQueue(async () => {
|
|
649
|
+
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
|
|
650
|
+
if (!confirm) {
|
|
651
|
+
return {
|
|
652
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
653
|
+
action: 'delete_table_preview',
|
|
654
|
+
tableId,
|
|
655
|
+
tableName: tableData.name,
|
|
656
|
+
columnCount: (tableData.columns || []).length,
|
|
657
|
+
relationCount: (tableData.relations || []).length,
|
|
658
|
+
destructive: true,
|
|
659
|
+
next: 'Call delete_table again with confirm=true to delete metadata, routes, derived FK/junction structures, the physical table, and all table data.',
|
|
660
|
+
}, null, 2) }],
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
assertGlobalRulesAck(globalRulesAckKey);
|
|
664
|
+
const result = await fetchAPI(ENFYRA_API_URL, `/enfyra_table/${tableId}`, {
|
|
665
|
+
method: 'DELETE',
|
|
666
|
+
});
|
|
667
|
+
return jsonContent({
|
|
668
|
+
action: 'table_deleted',
|
|
669
|
+
tableId,
|
|
670
|
+
result,
|
|
671
|
+
});
|
|
672
|
+
}));
|
|
673
|
+
// ─── CREATE COLUMN ───
|
|
674
|
+
server.tool('create_column', [
|
|
675
|
+
'Add a column to an existing table via PATCH /enfyra_table/{tableId}.',
|
|
676
|
+
'Columns are managed through cascade with enfyra_table — there is NO direct /enfyra_column endpoint.',
|
|
677
|
+
'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.',
|
|
678
|
+
'Generated metadata projections such as createdAt, updatedAt, or relation-derived FK display fields without id are not valid cascade rows and are skipped.',
|
|
679
|
+
'Run schema changes sequentially — migration locks DB per operation.',
|
|
680
|
+
].join(' '), {
|
|
681
|
+
...columnCreateSchema,
|
|
682
|
+
}, appendColumnToTable);
|
|
683
|
+
// ─── UPDATE COLUMN ───
|
|
684
|
+
server.tool('update_column', [
|
|
685
|
+
'Update an existing column on a table via PATCH /enfyra_table/{tableId}.',
|
|
686
|
+
'Reads full table metadata, keeps only persisted rows with id/_id, modifies the target column, PATCHes the table, and verifies unrelated columns survived.',
|
|
687
|
+
'Generated metadata projections such as createdAt, updatedAt, or relation-derived FK display fields without id are skipped.',
|
|
688
|
+
'Run schema changes sequentially — migration locks DB per operation.',
|
|
689
|
+
].join(' '), {
|
|
690
|
+
tableId: z.string().describe('Table definition ID.'),
|
|
691
|
+
columnId: z.string().describe('Column definition ID to update.'),
|
|
692
|
+
name: z.string().optional().describe('New column name.'),
|
|
693
|
+
type: z.string().optional().describe('New column type.'),
|
|
694
|
+
isNullable: z.boolean().optional().describe('Set nullable.'),
|
|
695
|
+
isPublished: z.boolean().optional().describe('Set column visibility baseline. false = unpublished (omitted from response unless allowed by field permission rules).'),
|
|
696
|
+
isUpdatable: z.boolean().optional().describe('Set false for immutable fields that should be stripped from update payloads.'),
|
|
697
|
+
defaultValue: z.string().optional().describe('New default value as JSON string.'),
|
|
698
|
+
description: z.string().optional().describe('New description.'),
|
|
699
|
+
options: z.string().optional().describe('New options as JSON string.'),
|
|
700
|
+
globalRulesAckKey: globalRulesAckParam(z),
|
|
701
|
+
}, async ({ tableId, columnId, name, type, isNullable, isPublished, isUpdatable, defaultValue, description, options, globalRulesAckKey }) => withSchemaQueue(async () => {
|
|
702
|
+
assertGlobalRulesAck(globalRulesAckKey);
|
|
703
|
+
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
|
|
704
|
+
if (!tableData) {
|
|
705
|
+
throw new Error(`Table with ID ${tableId} not found.`);
|
|
706
|
+
}
|
|
707
|
+
const existingColumns = getPatchableColumns(tableData.columns);
|
|
708
|
+
const beforeIds = existingColumns.map((column) => String(getId(column)));
|
|
709
|
+
if (!beforeIds.includes(String(columnId))) {
|
|
710
|
+
throw new Error(`Column ${columnId} was not found on table ${tableId}; refusing schema cascade patch.`);
|
|
711
|
+
}
|
|
712
|
+
const columns = existingColumns.map(col => {
|
|
713
|
+
const rest = normalizeColumnForTablePatch(col);
|
|
714
|
+
if (String(getId(col)) === String(columnId)) {
|
|
715
|
+
if (name !== undefined)
|
|
716
|
+
rest.name = name;
|
|
717
|
+
if (type !== undefined)
|
|
718
|
+
rest.type = type;
|
|
719
|
+
if (isNullable !== undefined)
|
|
720
|
+
rest.isNullable = isNullable;
|
|
721
|
+
if (isPublished !== undefined)
|
|
722
|
+
rest.isPublished = isPublished;
|
|
723
|
+
if (isUpdatable !== undefined)
|
|
724
|
+
rest.isUpdatable = isUpdatable;
|
|
725
|
+
if (defaultValue !== undefined)
|
|
726
|
+
rest.defaultValue = defaultValue;
|
|
727
|
+
if (description !== undefined)
|
|
728
|
+
rest.description = description;
|
|
729
|
+
if (options !== undefined)
|
|
730
|
+
rest.options = JSON.parse(options);
|
|
731
|
+
}
|
|
732
|
+
return rest;
|
|
733
|
+
});
|
|
734
|
+
const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, { columns });
|
|
735
|
+
await verifyColumnCascade(ENFYRA_API_URL, tableId, beforeIds, {
|
|
736
|
+
action: 'update',
|
|
737
|
+
columnId,
|
|
738
|
+
});
|
|
739
|
+
return jsonContent({
|
|
740
|
+
action: 'column_updated',
|
|
741
|
+
tableId,
|
|
742
|
+
columnId,
|
|
743
|
+
result,
|
|
744
|
+
});
|
|
745
|
+
}));
|
|
746
|
+
// ─── DELETE COLUMN ───
|
|
747
|
+
server.tool('delete_column', [
|
|
748
|
+
'Delete a column from a table via PATCH /enfyra_table/{tableId}.',
|
|
749
|
+
'Reads full table metadata, keeps only persisted rows with id/_id, removes the target, PATCHes the table, and verifies unrelated columns survived.',
|
|
750
|
+
'The physical column is dropped from the database. System columns (id, createdAt, updatedAt) cannot be deleted.',
|
|
751
|
+
'Run schema changes sequentially — migration locks DB per operation.',
|
|
752
|
+
].join(' '), {
|
|
753
|
+
...columnDeleteSchema,
|
|
754
|
+
}, removeColumnFromTable);
|
|
755
|
+
// ─── CREATE RELATION ───
|
|
756
|
+
server.tool('create_relation', [
|
|
757
|
+
'Create a relation between two tables (many-to-one, one-to-many, one-to-one, many-to-many).',
|
|
758
|
+
'sourceTableId and targetTableId may be table ids, exact table names, or aliases; MCP resolves them from metadata before mutation.',
|
|
759
|
+
'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.',
|
|
760
|
+
'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.',
|
|
761
|
+
'Run sequentially — DB migration locks per operation.',
|
|
762
|
+
].join(' '), {
|
|
763
|
+
...relationCreateSchema,
|
|
764
|
+
}, appendRelationToTable);
|
|
765
|
+
// ─── DELETE RELATION ───
|
|
766
|
+
server.tool('delete_relation', [
|
|
767
|
+
'Delete a relation from a table via PATCH /enfyra_table/{tableId}.',
|
|
768
|
+
'Fetches all relations, removes the target, and PATCHes the table.',
|
|
769
|
+
'Drops FK columns and junction tables (for many-to-many).',
|
|
770
|
+
].join(' '), {
|
|
771
|
+
...relationDeleteSchema,
|
|
772
|
+
}, removeRelationFromTable);
|
|
773
|
+
}
|
|
774
|
+
//# sourceMappingURL=table-tools.js.map
|