@enfyra/mcp-server 0.1.18 → 0.1.20
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 +156 -259
- package/dist/lib/mcp-examples.js.map +1 -1
- package/dist/lib/mcp-instructions.js +6 -5
- package/dist/lib/mcp-instructions.js.map +1 -1
- package/dist/lib/mutation-guards.d.ts +11 -0
- package/dist/lib/mutation-guards.js +47 -3
- package/dist/lib/mutation-guards.js.map +1 -1
- package/dist/lib/required-knowledge.d.ts +1 -1
- package/dist/lib/required-knowledge.js +7 -5
- package/dist/lib/required-knowledge.js.map +1 -1
- package/dist/lib/table-tools.js +396 -265
- package/dist/lib/table-tools.js.map +1 -1
- package/dist/lib/tool-routing.js +20 -14
- package/dist/lib/tool-routing.js.map +1 -1
- package/dist/mcp-server-entry.js +201 -68
- package/dist/mcp-server-entry.js.map +1 -1
- package/package.json +1 -1
package/dist/mcp-server-entry.js
CHANGED
|
@@ -26,7 +26,7 @@ import { WORKFLOW_SURFACES, discoverWorkflowRoutes } from './lib/tool-routing.js
|
|
|
26
26
|
import { getSupportedColumnTypesFromMetadata, registerTableTools } from './lib/table-tools.js';
|
|
27
27
|
import { registerPlatformOperationTools, validateExtensionCode } from './lib/platform-operation-tools.js';
|
|
28
28
|
import { registerRuntimeZoneTools } from './lib/runtime-zone-tools.js';
|
|
29
|
-
import { parseRecordData, prepareRecordMutation, validateScriptSourceIfPresent } from './lib/mutation-guards.js';
|
|
29
|
+
import { parseRecordBatchData, parseRecordData, prepareRecordBatchMutation, prepareRecordMutation, validateScriptSourceIfPresent } from './lib/mutation-guards.js';
|
|
30
30
|
import { assertDynamicCodeKnowledgeAck, assertDynamicCodeKnowledgeAckIf, assertExtensionKnowledgeAckIf, assertGlobalRulesAck, buildRequiredKnowledgePayload, dynamicCodeKnowledgeAckParam, extensionKnowledgeAckParam, globalRulesAckParam, } from './lib/required-knowledge.js';
|
|
31
31
|
import { validateMainTableRoutePath } from './lib/route-guards.js';
|
|
32
32
|
import { installColumnarToolFormatter, jsonContent } from './lib/response-format.js';
|
|
@@ -58,7 +58,7 @@ const CAPABILITY_AREAS = [
|
|
|
58
58
|
{
|
|
59
59
|
area: 'GraphQL',
|
|
60
60
|
tables: ['enfyra_graphql'],
|
|
61
|
-
workflow: 'Enable per table through enfyra_graphql or
|
|
61
|
+
workflow: 'Enable per table through enfyra_graphql or update_tables graphqlEnabled. GraphQL table data requires Bearer auth; anonymous root or schema probes may return 200 without exposing table data.',
|
|
62
62
|
},
|
|
63
63
|
{
|
|
64
64
|
area: 'Files and storage',
|
|
@@ -558,11 +558,63 @@ async function prepareGenericMutation(tableName, data) {
|
|
|
558
558
|
data,
|
|
559
559
|
});
|
|
560
560
|
}
|
|
561
|
+
async function prepareGenericBatchMutation(tableName, records) {
|
|
562
|
+
const { tables } = await getMetadataTables();
|
|
563
|
+
return prepareRecordBatchMutation({
|
|
564
|
+
fetchAPI,
|
|
565
|
+
apiUrl: ENFYRA_API_URL,
|
|
566
|
+
tables,
|
|
567
|
+
tableName,
|
|
568
|
+
records,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
561
571
|
function assertKnowledgeForGenericMutation(tableName, data, { knowledgeAckKey, extensionKnowledgeAckKey }) {
|
|
562
572
|
const payload = parseRecordData(data);
|
|
563
573
|
assertDynamicCodeKnowledgeAckIf(SCRIPT_BACKED_TABLE_SET.has(tableName) && typeof payload.sourceCode === 'string', knowledgeAckKey);
|
|
564
574
|
assertExtensionKnowledgeAckIf(tableName === 'enfyra_extension' && typeof payload.code === 'string', extensionKnowledgeAckKey);
|
|
565
575
|
}
|
|
576
|
+
function assertKnowledgeForGenericBatchMutation(tableName, records, { knowledgeAckKey, extensionKnowledgeAckKey }) {
|
|
577
|
+
const payloads = parseRecordBatchData(records);
|
|
578
|
+
for (const payload of payloads) {
|
|
579
|
+
assertDynamicCodeKnowledgeAckIf(SCRIPT_BACKED_TABLE_SET.has(tableName) && typeof payload.sourceCode === 'string', knowledgeAckKey);
|
|
580
|
+
assertExtensionKnowledgeAckIf(tableName === 'enfyra_extension' && typeof payload.code === 'string', extensionKnowledgeAckKey);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
function parseBulkItemsArg(name, value) {
|
|
584
|
+
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
|
585
|
+
if (!Array.isArray(parsed)) {
|
|
586
|
+
throw new Error(`${name} must be a JSON array string. Pass one item in the array for a single mutation.`);
|
|
587
|
+
}
|
|
588
|
+
if (parsed.length === 0) {
|
|
589
|
+
throw new Error(`${name} must include at least one item.`);
|
|
590
|
+
}
|
|
591
|
+
parsed.forEach((item, index) => {
|
|
592
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
593
|
+
throw new Error(`${name}[${index}] must be a JSON object.`);
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
return parsed;
|
|
597
|
+
}
|
|
598
|
+
function assertMaxBulkItems(name, items, maxItems) {
|
|
599
|
+
if (items.length > maxItems) {
|
|
600
|
+
throw new Error(`${name} received ${items.length} items, above maxItems=${maxItems}. Split the batch deliberately.`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
function assertNoDuplicateBulkIds(name, items) {
|
|
604
|
+
const seen = new Set();
|
|
605
|
+
const duplicates = new Set();
|
|
606
|
+
for (const item of items) {
|
|
607
|
+
const id = String(item.id ?? '');
|
|
608
|
+
if (!id)
|
|
609
|
+
continue;
|
|
610
|
+
if (seen.has(id))
|
|
611
|
+
duplicates.add(id);
|
|
612
|
+
seen.add(id);
|
|
613
|
+
}
|
|
614
|
+
if (duplicates.size > 0) {
|
|
615
|
+
throw new Error(`${name} contains duplicate id(s): ${[...duplicates].join(', ')}. Split or merge duplicate writes so the sequential batch has one clear final mutation per record.`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
566
618
|
async function validateExtensionCodeForGenericMutation(tableName, payload, fallbackName) {
|
|
567
619
|
if (tableName !== 'enfyra_extension' || typeof payload?.code !== 'string')
|
|
568
620
|
return null;
|
|
@@ -844,20 +896,20 @@ server.tool('discover_enfyra_system', [
|
|
|
844
896
|
publicAccess: 'publicMethods controls anonymous REST access per route/method; otherwise Bearer JWT + routePermissions apply.',
|
|
845
897
|
routeTables: sample(routeTableList),
|
|
846
898
|
noRouteTables: sample(noRouteTableList),
|
|
847
|
-
canonicalCrudTools: 'query_table/
|
|
899
|
+
canonicalCrudTools: 'query_table reads route-backed tables. create_records/update_records/delete_records are the only generic write tools; pass arrays even for one item. They preflight arrays and run sequentially.',
|
|
848
900
|
customRouteWorkflow: 'For a new endpoint use create_route without mainTableId, then create_handler/create_pre_hook/create_post_hook. Do not create a table just to get a path.',
|
|
849
901
|
routeSamples: sample(routes, 25),
|
|
850
902
|
detailHint: 'Use get_all_routes({ search, limit }) or inspect_route({ path }) for route details. Use inspect_table({ tableName }) for table detail.',
|
|
851
903
|
},
|
|
852
904
|
schemaManagement: {
|
|
853
|
-
createTable: 'POST /enfyra_table supports isSingleRecord at create time
|
|
905
|
+
createTable: 'POST /enfyra_table supports isSingleRecord at create time. MCP create_tables accepts an array, creates tables/columns sequentially, then creates requested relations after all tables in the batch exist. It does not accept alias at create time; table name drives the default route/schema behavior.',
|
|
854
906
|
updateTable: 'PATCH /enfyra_table/:id is the canonical path for table property changes and column/relation schema changes.',
|
|
855
|
-
columns: 'enfyra_column has no REST route; use
|
|
907
|
+
columns: 'enfyra_column has no REST route; use create_tables/create_columns/update_columns/delete_columns. Use liveColumnTypes below; do not invent SQL dialect names.',
|
|
856
908
|
liveColumnTypes: getSupportedColumnTypesFromMetadata(metadata),
|
|
857
909
|
columnTypeGuidance: 'Use varchar for short strings, text/richtext for long prose, float for price/amount/rating/decimal-like values unless decimal is listed, simple-json for structured objects/arrays only when listed, and relations instead of *_id columns for links.',
|
|
858
910
|
relations: routeTables.has('enfyra_relation')
|
|
859
|
-
? 'enfyra_relation has a REST route for reads/metadata, but canonical schema migration is
|
|
860
|
-
: 'Use
|
|
911
|
+
? 'enfyra_relation has a REST route for reads/metadata, but canonical schema migration is create_relations/delete_relations or enfyra_table PATCH with the full relations array. Relation onDelete accepts CASCADE, SET NULL, or RESTRICT.'
|
|
912
|
+
: 'Use create_relations/delete_relations or enfyra_table PATCH with the full relations array. Relation onDelete accepts CASCADE, SET NULL, or RESTRICT.',
|
|
861
913
|
relationCascadeFkContract: 'Do not ask for or send physical FK/junction column names in relation create/update payloads. Enfyra derives fk/junction columns from relation propertyName/table metadata and hides FK columns from app schema/forms. Use targetTable, type, propertyName, inversePropertyName or mappedBy, isNullable, onDelete. Add inversePropertyName only when a concrete response, UI, deep query, aggregate sort/count, or parent-to-child traversal will use the reverse field.',
|
|
862
914
|
tableDefinitionRelations: (tableDefinition?.relations || []).map((rel) => rel.propertyName),
|
|
863
915
|
relationDefinitionRelations: (relationTable?.relations || []).map((rel) => rel.propertyName),
|
|
@@ -873,8 +925,8 @@ server.tool('discover_enfyra_system', [
|
|
|
873
925
|
enablement: 'A table appears in GraphQL when enfyra_graphql has an enabled row for that table. REST route availableMethods does not enable GraphQL.',
|
|
874
926
|
auth: 'GraphQL table data requires Authorization: Bearer <accessToken>; REST publicMethods do not make GraphQL table data anonymous. Anonymous root/schema probes may still return 200.',
|
|
875
927
|
management: routeTables.has('enfyra_graphql')
|
|
876
|
-
? 'Use
|
|
877
|
-
: 'Use
|
|
928
|
+
? 'Use update_tables graphqlEnabled or create_records/update_records on enfyra_graphql, then reload_graphql if needed.'
|
|
929
|
+
: 'Use update_tables graphqlEnabled, then reload_graphql if needed.',
|
|
878
930
|
gqlDefinitionColumns: (gqlDefinition?.columns || []).map((column) => column.name),
|
|
879
931
|
},
|
|
880
932
|
tableSamples: sample(tableNames, 40),
|
|
@@ -1023,7 +1075,7 @@ server.tool('discover_query_capabilities', [
|
|
|
1023
1075
|
? 'Use this table metadata primary column when available.'
|
|
1024
1076
|
: 'SQL commonly uses id; Mongo uses _id. Use table metadata primary column when available.',
|
|
1025
1077
|
relationNames: 'API relation operations use relation propertyName, not physical FK column names.',
|
|
1026
|
-
relationCascadeFkContract: 'When creating relations through
|
|
1078
|
+
relationCascadeFkContract: 'When creating relations through create_tables/create_relations/enfyra_table PATCH, never provide fkCol/fkColumn/foreignKeyColumn/sourceColumn/targetColumn/junction*Column. These are physical implementation details derived by Enfyra and hidden from app schema/forms. Add inversePropertyName only for a concrete reverse traversal such as parent deep child lists, response fields, UI sections, or aggregate sort/count.',
|
|
1027
1079
|
graphql: 'GraphQL query args also accept filter/sort/page/limit. Table data requires Bearer auth and table enablement via enfyra_graphql; anonymous root/schema probes may still return 200.',
|
|
1028
1080
|
},
|
|
1029
1081
|
table: tableName
|
|
@@ -1175,7 +1227,7 @@ server.tool('discover_script_contexts', [
|
|
|
1175
1227
|
},
|
|
1176
1228
|
socketInHttpOrFlow: 'HTTP/flow context can emitToUser/emitToRoom/emitToGateway/broadcast and roomSize, but cannot reply/join/leave/disconnect/emitToCurrentRoom/broadcastToRoom because there is no bound socket. emitToRoom requires an explicit gateway path: emitToRoom(path, room, event, data). roomSize(room) counts sockets in that room across registered gateways.',
|
|
1177
1229
|
packages: 'Server packages installed through install_package are exposed as $ctx.$pkgs.packageName in server scripts.',
|
|
1178
|
-
files: 'Upload helpers are on $storage; raw
|
|
1230
|
+
files: 'Upload helpers are on $storage; raw create_records on enfyra_file is not equivalent to multipart upload/storage rollback. For multipart request files, pass file: @UPLOADED_FILE to @STORAGE.$upload/@STORAGE.$update so Enfyra streams from disk-backed temp storage. Use @STORAGE.$registerFile only when the object already exists in storage and the script should create the enfyra_file record without uploading bytes. Use buffer only for small generated files.',
|
|
1179
1231
|
},
|
|
1180
1232
|
adminTesting: {
|
|
1181
1233
|
flowStep: 'Use test_flow_step or run_admin_test(kind=flow_step).',
|
|
@@ -1193,8 +1245,8 @@ server.tool('get_enfyra_api_context', [
|
|
|
1193
1245
|
'Use when the user asks which HTTP endpoint or full URL applies: combine enfyraApiUrl with paths from server instructions (GET/POST /{table}, PATCH/DELETE /{table}/{id}, no GET /{table}/{id}).',
|
|
1194
1246
|
'Auth: publicMethods on a route can allow a method without Bearer; otherwise JWT + routePermissions — see server instructions.',
|
|
1195
1247
|
'If path might differ from table name, use get_all_routes before asserting a URL.',
|
|
1196
|
-
'Same mapping as MCP tool → HTTP: query_table=GET /table?...,
|
|
1197
|
-
'GraphQL: see graphqlHttpUrl / graphqlSchemaUrl in response; enable per table via enfyra_graphql/
|
|
1248
|
+
'Same mapping as MCP tool → HTTP: query_table=GET /table?..., create_records=sequential POST /table, update_records=sequential PATCH /table/id, delete_records=sequential DELETE /table/id.',
|
|
1249
|
+
'GraphQL: see graphqlHttpUrl / graphqlSchemaUrl in response; enable per table via enfyra_graphql/update_tables graphqlEnabled and send Bearer auth for table data queries. Anonymous root/schema probes may still return 200.',
|
|
1198
1250
|
].join(' '), {}, async () => {
|
|
1199
1251
|
const base = ENFYRA_API_URL.replace(/\/$/, '');
|
|
1200
1252
|
const gql = buildGraphqlUrls(ENFYRA_API_URL);
|
|
@@ -1362,47 +1414,101 @@ server.tool('find_one_record', 'Find a single record by ID or filter. By ID uses
|
|
|
1362
1414
|
// ============================================================================
|
|
1363
1415
|
// CRUD TOOLS
|
|
1364
1416
|
// ============================================================================
|
|
1365
|
-
server.tool('
|
|
1417
|
+
server.tool('create_records', 'Create one or more route-backed records. Always pass a JSON array string; for one record, pass a one-item array. MCP preflights every item against live metadata first, then sends one POST per record sequentially; this is not a backend bulk endpoint or transaction.', {
|
|
1366
1418
|
tableName: z.string().describe('Table name to insert into'),
|
|
1367
|
-
|
|
1368
|
-
queryParams: z.string().optional().describe('Optional query params as JSON object string
|
|
1419
|
+
records: z.string().describe('Records as a JSON array string. Each item must be a JSON object using metadata-backed column names and relation propertyName values.'),
|
|
1420
|
+
queryParams: z.string().optional().describe('Optional query params as JSON object string applied to every POST, for route contracts that intentionally keep workflow fields out of the validated body.'),
|
|
1421
|
+
maxRecords: z.number().int().min(1).max(100).optional().default(100).describe('Safety cap for one MCP batch. Default/max is 100. For larger imports, split intentionally.'),
|
|
1369
1422
|
globalRulesAckKey: globalRulesAckParam(z),
|
|
1370
|
-
knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required only when
|
|
1371
|
-
extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required only when tableName is enfyra_extension and
|
|
1372
|
-
}, async ({ tableName,
|
|
1423
|
+
knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required only when any item contains sourceCode. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
|
|
1424
|
+
extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required only when tableName is enfyra_extension and any item contains code. Use extensionAckKey from get_enfyra_required_knowledge.'),
|
|
1425
|
+
}, async ({ tableName, records, queryParams, maxRecords, globalRulesAckKey, knowledgeAckKey, extensionKnowledgeAckKey }) => {
|
|
1373
1426
|
assertGlobalRulesAck(globalRulesAckKey);
|
|
1374
1427
|
validateTableName(tableName);
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1428
|
+
const parsedRecords = parseRecordBatchData(records);
|
|
1429
|
+
if (parsedRecords.length > maxRecords) {
|
|
1430
|
+
throw new Error(`create_records received ${parsedRecords.length} records, above maxRecords=${maxRecords}. Split the batch deliberately.`);
|
|
1431
|
+
}
|
|
1432
|
+
assertKnowledgeForGenericBatchMutation(tableName, parsedRecords, { knowledgeAckKey, extensionKnowledgeAckKey });
|
|
1433
|
+
const prepared = await prepareGenericBatchMutation(tableName, parsedRecords);
|
|
1434
|
+
const extensionValidations = [];
|
|
1435
|
+
for (const item of prepared.records) {
|
|
1436
|
+
extensionValidations.push(await validateExtensionCodeForGenericMutation(tableName, item.payload, item.payload?.name || item.index));
|
|
1437
|
+
}
|
|
1378
1438
|
const query = parseQueryParamsArg(queryParams);
|
|
1379
|
-
const
|
|
1439
|
+
const created = [];
|
|
1440
|
+
for (const item of prepared.records) {
|
|
1441
|
+
const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}`, query), { method: 'POST', body: JSON.stringify(item.payload) });
|
|
1442
|
+
created.push({
|
|
1443
|
+
index: item.index,
|
|
1444
|
+
...summarizeMutationResult(result, 'created', tableName),
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1380
1447
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1448
|
+
action: 'created_records',
|
|
1449
|
+
tableName,
|
|
1450
|
+
requested: parsedRecords.length,
|
|
1451
|
+
createdCount: created.length,
|
|
1452
|
+
sequential: true,
|
|
1453
|
+
transactional: false,
|
|
1454
|
+
preflight: {
|
|
1455
|
+
liveMetadataFieldsValidated: true,
|
|
1456
|
+
scriptValidatedBeforeAnyPost: prepared.records.some((item) => item.scriptValidation?.validated === true),
|
|
1457
|
+
extensionValidatedBeforeAnyPost: extensionValidations.some(Boolean),
|
|
1458
|
+
},
|
|
1459
|
+
created,
|
|
1460
|
+
detailHint: `Use query_table({ tableName: "${tableName}", fields: [...], limit: ${Math.min(created.length, 20)} }) to inspect created records when needed.`,
|
|
1384
1461
|
}, null, 2) }] };
|
|
1385
1462
|
});
|
|
1386
|
-
server.tool('
|
|
1463
|
+
server.tool('update_records', 'Update one or more records in one MCP call. Pass items as a JSON array string; for one update, pass one item. MCP preflights every item against live metadata and extension/script validators first, rejects duplicate ids, then PATCHes sequentially to avoid races and server overload.', {
|
|
1387
1464
|
tableName: z.string().describe('Table name'),
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
queryParams: z.string().optional().describe('Optional query params as JSON object string for route contracts that intentionally keep workflow fields out of the validated body.'),
|
|
1465
|
+
items: z.string().describe('JSON array string of update items: [{ "id": "...", "data": { ... }, "queryParams": { ... }? }]. data must use metadata-backed column names and relation propertyName values.'),
|
|
1466
|
+
maxItems: z.number().int().min(1).max(100).optional().default(100).describe('Safety cap for one MCP batch. Default/max is 100.'),
|
|
1391
1467
|
globalRulesAckKey: globalRulesAckParam(z),
|
|
1392
|
-
knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required only when data contains sourceCode. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
|
|
1393
|
-
extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required only when tableName is enfyra_extension and data contains code. Use extensionAckKey from get_enfyra_required_knowledge.'),
|
|
1394
|
-
}, async ({ tableName,
|
|
1468
|
+
knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required only when any item.data contains sourceCode. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
|
|
1469
|
+
extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required only when tableName is enfyra_extension and any item.data contains code. Use extensionAckKey from get_enfyra_required_knowledge.'),
|
|
1470
|
+
}, async ({ tableName, items, maxItems, globalRulesAckKey, knowledgeAckKey, extensionKnowledgeAckKey }) => {
|
|
1395
1471
|
assertGlobalRulesAck(globalRulesAckKey);
|
|
1396
1472
|
validateTableName(tableName);
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
const
|
|
1401
|
-
const
|
|
1473
|
+
const parsedItems = parseBulkItemsArg('items', items);
|
|
1474
|
+
assertMaxBulkItems('update_records', parsedItems, maxItems);
|
|
1475
|
+
assertNoDuplicateBulkIds('update_records', parsedItems);
|
|
1476
|
+
const preparedItems = [];
|
|
1477
|
+
const extensionValidations = [];
|
|
1478
|
+
for (const [index, item] of parsedItems.entries()) {
|
|
1479
|
+
if (!item.id)
|
|
1480
|
+
throw new Error(`items[${index}].id is required.`);
|
|
1481
|
+
if (!item.data || typeof item.data !== 'object' || Array.isArray(item.data)) {
|
|
1482
|
+
throw new Error(`items[${index}].data must be a JSON object.`);
|
|
1483
|
+
}
|
|
1484
|
+
assertKnowledgeForGenericMutation(tableName, JSON.stringify(item.data), { knowledgeAckKey, extensionKnowledgeAckKey });
|
|
1485
|
+
const prepared = await prepareGenericMutation(tableName, JSON.stringify(item.data));
|
|
1486
|
+
preparedItems.push({ index, id: item.id, queryParams: item.queryParams, prepared });
|
|
1487
|
+
extensionValidations.push(await validateExtensionCodeForGenericMutation(tableName, prepared.payload, item.id));
|
|
1488
|
+
}
|
|
1489
|
+
const updated = [];
|
|
1490
|
+
for (const item of preparedItems) {
|
|
1491
|
+
const query = parseQueryParamsArg(JSON.stringify(item.queryParams || {}));
|
|
1492
|
+
const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}/${encodeURIComponent(String(item.id))}`, query), { method: 'PATCH', body: JSON.stringify(item.prepared.payload) });
|
|
1493
|
+
updated.push({
|
|
1494
|
+
index: item.index,
|
|
1495
|
+
id: item.id,
|
|
1496
|
+
...summarizeMutationResult(result, 'updated', tableName),
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1402
1499
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1500
|
+
action: 'updated_records',
|
|
1501
|
+
tableName,
|
|
1502
|
+
requested: parsedItems.length,
|
|
1503
|
+
updatedCount: updated.length,
|
|
1504
|
+
sequential: true,
|
|
1505
|
+
duplicateIdsRejected: true,
|
|
1506
|
+
preflight: {
|
|
1507
|
+
liveMetadataFieldsValidated: true,
|
|
1508
|
+
scriptValidatedBeforeAnyPatch: preparedItems.some((item) => item.prepared.scriptValidation?.validated === true),
|
|
1509
|
+
extensionValidatedBeforeAnyPatch: extensionValidations.some(Boolean),
|
|
1510
|
+
},
|
|
1511
|
+
updated,
|
|
1406
1512
|
}, null, 2) }] };
|
|
1407
1513
|
});
|
|
1408
1514
|
server.tool('get_script_source', [
|
|
@@ -1445,7 +1551,7 @@ server.tool('patch_script_source', [
|
|
|
1445
1551
|
}, async ({ tableName, id, oldText, newText, occurrence, expectedSourceSha256, scriptLanguage, apply, globalRulesAckKey, knowledgeAckKey }) => {
|
|
1446
1552
|
const { record, sourceField, sourceCode } = await fetchScriptRecord(tableName, id);
|
|
1447
1553
|
if (sourceField !== 'sourceCode') {
|
|
1448
|
-
throw new Error(`patch_script_source only saves sourceCode records. Record uses "${sourceField}"; use
|
|
1554
|
+
throw new Error(`patch_script_source only saves sourceCode records. Record uses "${sourceField}"; use update_records intentionally for this legacy field.`);
|
|
1449
1555
|
}
|
|
1450
1556
|
const beforeHash = sha256(sourceCode);
|
|
1451
1557
|
if (expectedSourceSha256 && expectedSourceSha256 !== beforeHash) {
|
|
@@ -1520,42 +1626,69 @@ server.tool('update_script_source', [
|
|
|
1520
1626
|
scriptValidation: prepared.scriptValidation,
|
|
1521
1627
|
}, null, 2) }] };
|
|
1522
1628
|
});
|
|
1523
|
-
server.tool('
|
|
1629
|
+
server.tool('delete_records', 'Delete one or more route-backed records in one MCP call. Pass items as a JSON array string; for one delete, pass one item. The tool previews every target when confirm=false, rejects duplicate ids, and deletes sequentially when confirm=true.', {
|
|
1524
1630
|
tableName: z.string().describe('Table name'),
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
confirm: z.boolean().optional().default(false).describe('Required true to apply
|
|
1631
|
+
items: z.string().describe('JSON array string of delete items: [{ "id": "...", "queryParams": { ... }? }].'),
|
|
1632
|
+
maxItems: z.number().int().min(1).max(100).optional().default(100).describe('Safety cap for one MCP batch. Default/max is 100.'),
|
|
1633
|
+
confirm: z.boolean().optional().default(false).describe('Required true to apply destructive deletes. Omit/false returns previews only.'),
|
|
1528
1634
|
globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when confirm=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
|
|
1529
|
-
}, async ({ tableName,
|
|
1635
|
+
}, async ({ tableName, items, maxItems, confirm, globalRulesAckKey }) => {
|
|
1530
1636
|
validateTableName(tableName);
|
|
1637
|
+
const parsedItems = parseBulkItemsArg('items', items);
|
|
1638
|
+
assertMaxBulkItems('delete_records', parsedItems, maxItems);
|
|
1639
|
+
assertNoDuplicateBulkIds('delete_records', parsedItems);
|
|
1640
|
+
for (const [index, item] of parsedItems.entries()) {
|
|
1641
|
+
if (!item.id)
|
|
1642
|
+
throw new Error(`items[${index}].id is required.`);
|
|
1643
|
+
}
|
|
1531
1644
|
const primaryKey = await getPrimaryFieldName(tableName);
|
|
1532
1645
|
if (!confirm) {
|
|
1533
|
-
const
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1646
|
+
const previews = [];
|
|
1647
|
+
for (const [index, item] of parsedItems.entries()) {
|
|
1648
|
+
const query = new URLSearchParams({
|
|
1649
|
+
filter: JSON.stringify({ [primaryKey]: { _eq: item.id } }),
|
|
1650
|
+
limit: '1',
|
|
1651
|
+
fields: primaryKey,
|
|
1652
|
+
});
|
|
1653
|
+
const preview = await fetchAPI(ENFYRA_API_URL, `/${tableName}?${query.toString()}`).catch((error) => ({ error: String(error?.message || error) }));
|
|
1654
|
+
previews.push({
|
|
1655
|
+
index,
|
|
1656
|
+
id: item.id,
|
|
1657
|
+
preview: preview?.data?.[0] || null,
|
|
1658
|
+
previewError: preview?.error,
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1539
1661
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1540
|
-
action: '
|
|
1662
|
+
action: 'delete_records_preview',
|
|
1541
1663
|
tableName,
|
|
1542
|
-
id,
|
|
1543
1664
|
primaryKey,
|
|
1544
|
-
|
|
1545
|
-
|
|
1665
|
+
requested: parsedItems.length,
|
|
1666
|
+
duplicateIdsRejected: true,
|
|
1546
1667
|
destructive: true,
|
|
1547
|
-
|
|
1668
|
+
previews,
|
|
1669
|
+
next: 'Call delete_records again with the same items and confirm=true to delete these route-backed records sequentially.',
|
|
1548
1670
|
}, null, 2) }] };
|
|
1549
1671
|
}
|
|
1550
1672
|
assertGlobalRulesAck(globalRulesAckKey);
|
|
1551
|
-
const
|
|
1552
|
-
const
|
|
1673
|
+
const deleted = [];
|
|
1674
|
+
for (const [index, item] of parsedItems.entries()) {
|
|
1675
|
+
const query = parseQueryParamsArg(JSON.stringify(item.queryParams || {}));
|
|
1676
|
+
const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}/${encodeURIComponent(String(item.id))}`, query), { method: 'DELETE' });
|
|
1677
|
+
deleted.push({
|
|
1678
|
+
index,
|
|
1679
|
+
id: item.id,
|
|
1680
|
+
statusCode: result?.statusCode,
|
|
1681
|
+
success: result?.success,
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1553
1684
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1554
|
-
action: '
|
|
1685
|
+
action: 'deleted_records',
|
|
1555
1686
|
tableName,
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1687
|
+
requested: parsedItems.length,
|
|
1688
|
+
deletedCount: deleted.length,
|
|
1689
|
+
sequential: true,
|
|
1690
|
+
duplicateIdsRejected: true,
|
|
1691
|
+
deleted,
|
|
1559
1692
|
}, null, 2) }] };
|
|
1560
1693
|
});
|
|
1561
1694
|
server.tool('list_methods', 'List enfyra_method records with their UI colors. Use this before creating route methods or method-colored UI.', {}, async () => {
|
|
@@ -1573,7 +1706,7 @@ server.tool('list_methods', 'List enfyra_method records with their UI colors. Us
|
|
|
1573
1706
|
appUi: '/settings/methods',
|
|
1574
1707
|
}, null, 2) }] };
|
|
1575
1708
|
});
|
|
1576
|
-
server.tool('create_method', 'Create a enfyra_method record with app badge colors. Prefer this over generic
|
|
1709
|
+
server.tool('create_method', 'Create a enfyra_method record with app badge colors. Prefer this over generic create_records for enfyra_method.', {
|
|
1577
1710
|
method: z.string().describe('Uppercase method name, e.g. GET, POST, PUT, CUSTOM_METHOD. Must start with A-Z and contain only A-Z, 0-9, or underscore.'),
|
|
1578
1711
|
buttonColor: z.string().describe('Badge background color as full hex, e.g. #dbeafe.'),
|
|
1579
1712
|
textColor: z.string().describe('Badge text color as full hex, e.g. #1d4ed8.'),
|
|
@@ -1603,7 +1736,7 @@ server.tool('create_method', 'Create a enfyra_method record with app badge color
|
|
|
1603
1736
|
appUi: '/settings/methods',
|
|
1604
1737
|
}, null, 2) }] };
|
|
1605
1738
|
});
|
|
1606
|
-
server.tool('update_method', 'Update a enfyra_method record color pair, and optionally rename non-system methods. Prefer this over generic
|
|
1739
|
+
server.tool('update_method', 'Update a enfyra_method record color pair, and optionally rename non-system methods. Prefer this over generic update_records for enfyra_method.', {
|
|
1607
1740
|
id: z.string().optional().describe('Method record id. If omitted, method is used to find the record.'),
|
|
1608
1741
|
method: z.string().optional().describe('Existing method name to find, or new name when id is provided.'),
|
|
1609
1742
|
buttonColor: z.string().optional().describe('Badge background color as full hex, e.g. #dbeafe.'),
|
|
@@ -2187,10 +2320,10 @@ server.tool('get_all_routes', 'List route definitions with minimal fields. Every
|
|
|
2187
2320
|
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
2188
2321
|
});
|
|
2189
2322
|
server.tool('create_route', [
|
|
2190
|
-
'**Use this when the user wants a new REST API route or path** — not `
|
|
2323
|
+
'**Use this when the user wants a new REST API route or path** — not `create_tables`. Custom routes must omit `mainTableId`.',
|
|
2191
2324
|
'`mainTableId` is only a marker for canonical table routes such as `/orders`; do not set it for `/orders/stats`, `/reports/summary`, `/auth/login`, or any custom path.',
|
|
2192
2325
|
'Do NOT create a new enfyra_table only to expose an endpoint; create a route without `mainTableId`, then have the handler/hook query explicit repos such as `$ctx.$repos.orders`.',
|
|
2193
|
-
'availableMethods = which REST verbs the route responds to. publicMethods = which REST verbs are public (no auth). GraphQL is enabled separately through enfyra_graphql/
|
|
2326
|
+
'availableMethods = which REST verbs the route responds to. publicMethods = which REST verbs are public (no auth). GraphQL is enabled separately through enfyra_graphql/update_tables graphqlEnabled.',
|
|
2194
2327
|
'After creation the tool auto-reloads routes. Then create handlers for specific methods via create_handler on this route id.',
|
|
2195
2328
|
'Flow: create_route → create_handler (per method) → optionally create_pre_hook / create_post_hook → test via HTTP or admin test APIs (see server instructions).',
|
|
2196
2329
|
].join(' '), {
|