@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.
@@ -17,6 +17,9 @@ function asNonEmptyStringTuple(values, label) {
17
17
  }
18
18
  return values;
19
19
  }
20
+ function bulkObjectArrayParam(z, label) {
21
+ 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.`);
22
+ }
20
23
  // Import modules
21
24
  import { exchangeApiToken, getValidToken, getTokenExpiry, initAuth } from './lib/auth.js';
22
25
  import { fetchAPI, validateFilter, validateTableName } from './lib/fetch.js';
@@ -58,7 +61,7 @@ const CAPABILITY_AREAS = [
58
61
  {
59
62
  area: 'GraphQL',
60
63
  tables: ['enfyra_graphql'],
61
- workflow: 'Enable per table through enfyra_graphql or update_table graphqlEnabled. GraphQL table data requires Bearer auth; anonymous root or schema probes may return 200 without exposing table data.',
64
+ 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
65
  },
63
66
  {
64
67
  area: 'Files and storage',
@@ -580,6 +583,41 @@ function assertKnowledgeForGenericBatchMutation(tableName, records, { knowledgeA
580
583
  assertExtensionKnowledgeAckIf(tableName === 'enfyra_extension' && typeof payload.code === 'string', extensionKnowledgeAckKey);
581
584
  }
582
585
  }
586
+ function parseBulkItemsArg(name, value) {
587
+ const parsed = typeof value === 'string' ? JSON.parse(value) : value;
588
+ if (!Array.isArray(parsed)) {
589
+ throw new Error(`${name} must be a JSON array. Pass one object in the array for a single mutation.`);
590
+ }
591
+ if (parsed.length === 0) {
592
+ throw new Error(`${name} must include at least one item.`);
593
+ }
594
+ parsed.forEach((item, index) => {
595
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
596
+ throw new Error(`${name}[${index}] must be a JSON object.`);
597
+ }
598
+ });
599
+ return parsed;
600
+ }
601
+ function assertMaxBulkItems(name, items, maxItems) {
602
+ if (items.length > maxItems) {
603
+ throw new Error(`${name} received ${items.length} items, above maxItems=${maxItems}. Split the batch deliberately.`);
604
+ }
605
+ }
606
+ function assertNoDuplicateBulkIds(name, items) {
607
+ const seen = new Set();
608
+ const duplicates = new Set();
609
+ for (const item of items) {
610
+ const id = String(item.id ?? '');
611
+ if (!id)
612
+ continue;
613
+ if (seen.has(id))
614
+ duplicates.add(id);
615
+ seen.add(id);
616
+ }
617
+ if (duplicates.size > 0) {
618
+ 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.`);
619
+ }
620
+ }
583
621
  async function validateExtensionCodeForGenericMutation(tableName, payload, fallbackName) {
584
622
  if (tableName !== 'enfyra_extension' || typeof payload?.code !== 'string')
585
623
  return null;
@@ -861,20 +899,20 @@ server.tool('discover_enfyra_system', [
861
899
  publicAccess: 'publicMethods controls anonymous REST access per route/method; otherwise Bearer JWT + routePermissions apply.',
862
900
  routeTables: sample(routeTableList),
863
901
  noRouteTables: sample(noRouteTableList),
864
- canonicalCrudTools: 'query_table/create_record/create_records/update_record/delete_record use dynamic REST routes and only work for route-backed tables. create_record is single-object only; create_records accepts an array, preflights every item against live metadata, then posts sequentially.',
902
+ canonicalCrudTools: 'query_table reads route-backed tables. create_records/update_records/delete_records are the only generic write tools; pass native arrays even for one item. They preflight arrays and run sequentially.',
865
903
  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.',
866
904
  routeSamples: sample(routes, 25),
867
905
  detailHint: 'Use get_all_routes({ search, limit }) or inspect_route({ path }) for route details. Use inspect_table({ tableName }) for table detail.',
868
906
  },
869
907
  schemaManagement: {
870
- createTable: 'POST /enfyra_table supports isSingleRecord at create time and supports columns and relations arrays in the same cascade call. MCP create_table exposes isSingleRecord, columns, and relations directly. It does not accept alias at create time; table name drives the default route/schema behavior.',
908
+ createTable: 'POST /enfyra_table supports isSingleRecord at create time. MCP create_tables accepts a native 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.',
871
909
  updateTable: 'PATCH /enfyra_table/:id is the canonical path for table property changes and column/relation schema changes.',
872
- columns: 'enfyra_column has no REST route; use create_table/create_column/update_column/delete_column. Use liveColumnTypes below; do not invent SQL dialect names.',
910
+ 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.',
873
911
  liveColumnTypes: getSupportedColumnTypesFromMetadata(metadata),
874
912
  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.',
875
913
  relations: routeTables.has('enfyra_relation')
876
- ? 'enfyra_relation has a REST route for reads/metadata, but canonical schema migration is create_relation/delete_relation or enfyra_table PATCH with the full relations array. Relation onDelete accepts CASCADE, SET NULL, or RESTRICT.'
877
- : 'Use create_relation/delete_relation or enfyra_table PATCH with the full relations array. Relation onDelete accepts CASCADE, SET NULL, or RESTRICT.',
914
+ ? '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.'
915
+ : 'Use create_relations/delete_relations or enfyra_table PATCH with the full relations array. Relation onDelete accepts CASCADE, SET NULL, or RESTRICT.',
878
916
  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.',
879
917
  tableDefinitionRelations: (tableDefinition?.relations || []).map((rel) => rel.propertyName),
880
918
  relationDefinitionRelations: (relationTable?.relations || []).map((rel) => rel.propertyName),
@@ -890,8 +928,8 @@ server.tool('discover_enfyra_system', [
890
928
  enablement: 'A table appears in GraphQL when enfyra_graphql has an enabled row for that table. REST route availableMethods does not enable GraphQL.',
891
929
  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.',
892
930
  management: routeTables.has('enfyra_graphql')
893
- ? 'Use update_table graphqlEnabled or create/update records on enfyra_graphql, then reload_graphql if needed.'
894
- : 'Use update_table graphqlEnabled, then reload_graphql if needed.',
931
+ ? 'Use update_tables graphqlEnabled or create_records/update_records on enfyra_graphql, then reload_graphql if needed.'
932
+ : 'Use update_tables graphqlEnabled, then reload_graphql if needed.',
895
933
  gqlDefinitionColumns: (gqlDefinition?.columns || []).map((column) => column.name),
896
934
  },
897
935
  tableSamples: sample(tableNames, 40),
@@ -1040,7 +1078,7 @@ server.tool('discover_query_capabilities', [
1040
1078
  ? 'Use this table metadata primary column when available.'
1041
1079
  : 'SQL commonly uses id; Mongo uses _id. Use table metadata primary column when available.',
1042
1080
  relationNames: 'API relation operations use relation propertyName, not physical FK column names.',
1043
- relationCascadeFkContract: 'When creating relations through create_table/create_relation/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.',
1081
+ 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.',
1044
1082
  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.',
1045
1083
  },
1046
1084
  table: tableName
@@ -1192,7 +1230,7 @@ server.tool('discover_script_contexts', [
1192
1230
  },
1193
1231
  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.',
1194
1232
  packages: 'Server packages installed through install_package are exposed as $ctx.$pkgs.packageName in server scripts.',
1195
- files: 'Upload helpers are on $storage; raw create_record 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.',
1233
+ 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.',
1196
1234
  },
1197
1235
  adminTesting: {
1198
1236
  flowStep: 'Use test_flow_step or run_admin_test(kind=flow_step).',
@@ -1210,8 +1248,8 @@ server.tool('get_enfyra_api_context', [
1210
1248
  '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}).',
1211
1249
  'Auth: publicMethods on a route can allow a method without Bearer; otherwise JWT + routePermissions — see server instructions.',
1212
1250
  'If path might differ from table name, use get_all_routes before asserting a URL.',
1213
- 'Same mapping as MCP tool → HTTP: query_table=GET /table?..., create_record=POST /table, update_record=PATCH /table/id, delete_record=DELETE /table/id.',
1214
- 'GraphQL: see graphqlHttpUrl / graphqlSchemaUrl in response; enable per table via enfyra_graphql/update_table graphqlEnabled and send Bearer auth for table data queries. Anonymous root/schema probes may still return 200.',
1251
+ '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.',
1252
+ '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.',
1215
1253
  ].join(' '), {}, async () => {
1216
1254
  const base = ENFYRA_API_URL.replace(/\/$/, '');
1217
1255
  const gql = buildGraphqlUrls(ENFYRA_API_URL);
@@ -1379,30 +1417,9 @@ server.tool('find_one_record', 'Find a single record by ID or filter. By ID uses
1379
1417
  // ============================================================================
1380
1418
  // CRUD TOOLS
1381
1419
  // ============================================================================
1382
- server.tool('create_record', 'Create exactly one record in a route-backed table. Pass a single JSON object, not an array. The tool preflights body keys against live metadata before POST, validates sourceCode before saving script-backed records, and validates enfyra_extension.code before saving extension records. For multiple records, use create_records.', {
1420
+ server.tool('create_records', 'Create one or more route-backed records. Always pass records as a native JSON array; 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. JSON string arrays are accepted only for older MCP clients.', {
1383
1421
  tableName: z.string().describe('Table name to insert into'),
1384
- data: z.string().describe('Single record data as a JSON object string. Arrays are intentionally rejected; use create_records for batch seeding.'),
1385
- queryParams: z.string().optional().describe('Optional query params as JSON object string, e.g. {"expired_at":"2026-09-20"}. Use for route contracts that intentionally keep workflow fields out of the validated body.'),
1386
- globalRulesAckKey: globalRulesAckParam(z),
1387
- knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required only when data contains sourceCode. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1388
- extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required only when tableName is enfyra_extension and data contains code. Use extensionAckKey from get_enfyra_required_knowledge.'),
1389
- }, async ({ tableName, data, queryParams, globalRulesAckKey, knowledgeAckKey, extensionKnowledgeAckKey }) => {
1390
- assertGlobalRulesAck(globalRulesAckKey);
1391
- validateTableName(tableName);
1392
- assertKnowledgeForGenericMutation(tableName, data, { knowledgeAckKey, extensionKnowledgeAckKey });
1393
- const prepared = await prepareGenericMutation(tableName, data);
1394
- const extensionValidation = await validateExtensionCodeForGenericMutation(tableName, prepared.payload, prepared.payload?.name);
1395
- const query = parseQueryParamsArg(queryParams);
1396
- const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}`, query), { method: 'POST', body: JSON.stringify(prepared.payload) });
1397
- return { content: [{ type: 'text', text: JSON.stringify({
1398
- ...summarizeMutationResult(result, 'created', tableName),
1399
- scriptValidation: prepared.scriptValidation,
1400
- extensionValidation,
1401
- }, null, 2) }] };
1402
- });
1403
- server.tool('create_records', 'Create multiple records in one MCP call for route-backed table seeding. Pass a JSON array string. MCP preflights every item against live metadata first, then sends one POST per record sequentially; this is not a backend bulk endpoint or transaction.', {
1404
- tableName: z.string().describe('Table name to insert into'),
1405
- 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.'),
1422
+ records: bulkObjectArrayParam(z, 'Records').describe('Records as a native JSON array. Each item must be a JSON object using metadata-backed column names and relation propertyName values.'),
1406
1423
  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.'),
1407
1424
  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.'),
1408
1425
  globalRulesAckKey: globalRulesAckParam(z),
@@ -1446,26 +1463,55 @@ server.tool('create_records', 'Create multiple records in one MCP call for route
1446
1463
  detailHint: `Use query_table({ tableName: "${tableName}", fields: [...], limit: ${Math.min(created.length, 20)} }) to inspect created records when needed.`,
1447
1464
  }, null, 2) }] };
1448
1465
  });
1449
- server.tool('update_record', 'Update an existing record by ID using PATCH. The tool validates body keys against live metadata, validates sourceCode before saving script-backed records, and validates enfyra_extension.code before saving extension records. Prefer update_extension_code for normal extension edits.', {
1466
+ server.tool('update_records', 'Update one or more records in one MCP call. Pass items as a native JSON array; 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. JSON string arrays are accepted only for older MCP clients.', {
1450
1467
  tableName: z.string().describe('Table name'),
1451
- id: z.string().describe('Record ID to update'),
1452
- data: z.string().describe('Fields to update as JSON string'),
1453
- 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.'),
1468
+ items: bulkObjectArrayParam(z, 'Update items').describe('Native JSON array of update items: [{ "id": "...", "data": { ... }, "queryParams": { ... }? }]. data must use metadata-backed column names and relation propertyName values.'),
1469
+ maxItems: z.number().int().min(1).max(100).optional().default(100).describe('Safety cap for one MCP batch. Default/max is 100.'),
1454
1470
  globalRulesAckKey: globalRulesAckParam(z),
1455
- knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required only when data contains sourceCode. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1456
- extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required only when tableName is enfyra_extension and data contains code. Use extensionAckKey from get_enfyra_required_knowledge.'),
1457
- }, async ({ tableName, id, data, queryParams, globalRulesAckKey, knowledgeAckKey, extensionKnowledgeAckKey }) => {
1471
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required only when any item.data contains sourceCode. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1472
+ 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.'),
1473
+ }, async ({ tableName, items, maxItems, globalRulesAckKey, knowledgeAckKey, extensionKnowledgeAckKey }) => {
1458
1474
  assertGlobalRulesAck(globalRulesAckKey);
1459
1475
  validateTableName(tableName);
1460
- assertKnowledgeForGenericMutation(tableName, data, { knowledgeAckKey, extensionKnowledgeAckKey });
1461
- const prepared = await prepareGenericMutation(tableName, data);
1462
- const extensionValidation = await validateExtensionCodeForGenericMutation(tableName, prepared.payload, id);
1463
- const query = parseQueryParamsArg(queryParams);
1464
- const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}/${id}`, query), { method: 'PATCH', body: JSON.stringify(prepared.payload) });
1476
+ const parsedItems = parseBulkItemsArg('items', items);
1477
+ assertMaxBulkItems('update_records', parsedItems, maxItems);
1478
+ assertNoDuplicateBulkIds('update_records', parsedItems);
1479
+ const preparedItems = [];
1480
+ const extensionValidations = [];
1481
+ for (const [index, item] of parsedItems.entries()) {
1482
+ if (!item.id)
1483
+ throw new Error(`items[${index}].id is required.`);
1484
+ if (!item.data || typeof item.data !== 'object' || Array.isArray(item.data)) {
1485
+ throw new Error(`items[${index}].data must be a JSON object.`);
1486
+ }
1487
+ assertKnowledgeForGenericMutation(tableName, JSON.stringify(item.data), { knowledgeAckKey, extensionKnowledgeAckKey });
1488
+ const prepared = await prepareGenericMutation(tableName, JSON.stringify(item.data));
1489
+ preparedItems.push({ index, id: item.id, queryParams: item.queryParams, prepared });
1490
+ extensionValidations.push(await validateExtensionCodeForGenericMutation(tableName, prepared.payload, item.id));
1491
+ }
1492
+ const updated = [];
1493
+ for (const item of preparedItems) {
1494
+ const query = parseQueryParamsArg(JSON.stringify(item.queryParams || {}));
1495
+ const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}/${encodeURIComponent(String(item.id))}`, query), { method: 'PATCH', body: JSON.stringify(item.prepared.payload) });
1496
+ updated.push({
1497
+ index: item.index,
1498
+ id: item.id,
1499
+ ...summarizeMutationResult(result, 'updated', tableName),
1500
+ });
1501
+ }
1465
1502
  return { content: [{ type: 'text', text: JSON.stringify({
1466
- ...summarizeMutationResult(result, 'updated', tableName),
1467
- scriptValidation: prepared.scriptValidation,
1468
- extensionValidation,
1503
+ action: 'updated_records',
1504
+ tableName,
1505
+ requested: parsedItems.length,
1506
+ updatedCount: updated.length,
1507
+ sequential: true,
1508
+ duplicateIdsRejected: true,
1509
+ preflight: {
1510
+ liveMetadataFieldsValidated: true,
1511
+ scriptValidatedBeforeAnyPatch: preparedItems.some((item) => item.prepared.scriptValidation?.validated === true),
1512
+ extensionValidatedBeforeAnyPatch: extensionValidations.some(Boolean),
1513
+ },
1514
+ updated,
1469
1515
  }, null, 2) }] };
1470
1516
  });
1471
1517
  server.tool('get_script_source', [
@@ -1508,7 +1554,7 @@ server.tool('patch_script_source', [
1508
1554
  }, async ({ tableName, id, oldText, newText, occurrence, expectedSourceSha256, scriptLanguage, apply, globalRulesAckKey, knowledgeAckKey }) => {
1509
1555
  const { record, sourceField, sourceCode } = await fetchScriptRecord(tableName, id);
1510
1556
  if (sourceField !== 'sourceCode') {
1511
- throw new Error(`patch_script_source only saves sourceCode records. Record uses "${sourceField}"; use update_record intentionally for this legacy field.`);
1557
+ throw new Error(`patch_script_source only saves sourceCode records. Record uses "${sourceField}"; use update_records intentionally for this legacy field.`);
1512
1558
  }
1513
1559
  const beforeHash = sha256(sourceCode);
1514
1560
  if (expectedSourceSha256 && expectedSourceSha256 !== beforeHash) {
@@ -1583,42 +1629,69 @@ server.tool('update_script_source', [
1583
1629
  scriptValidation: prepared.scriptValidation,
1584
1630
  }, null, 2) }] };
1585
1631
  });
1586
- server.tool('delete_record', 'Delete a record by ID', {
1632
+ server.tool('delete_records', 'Delete one or more route-backed records in one MCP call. Pass items as a native JSON array; for one delete, pass one item. The tool previews every target when confirm=false, rejects duplicate ids, and deletes sequentially when confirm=true. JSON string arrays are accepted only for older MCP clients.', {
1587
1633
  tableName: z.string().describe('Table name'),
1588
- id: z.string().describe('Record ID to delete'),
1589
- queryParams: z.string().optional().describe('Optional query params as JSON object string for route-specific confirmation contracts.'),
1590
- confirm: z.boolean().optional().default(false).describe('Required true to apply the destructive delete. Omit/false returns a preview only.'),
1634
+ items: bulkObjectArrayParam(z, 'Delete items').describe('Native JSON array of delete items: [{ "id": "...", "queryParams": { ... }? }].'),
1635
+ maxItems: z.number().int().min(1).max(100).optional().default(100).describe('Safety cap for one MCP batch. Default/max is 100.'),
1636
+ confirm: z.boolean().optional().default(false).describe('Required true to apply destructive deletes. Omit/false returns previews only.'),
1591
1637
  globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when confirm=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
1592
- }, async ({ tableName, id, queryParams, confirm, globalRulesAckKey }) => {
1638
+ }, async ({ tableName, items, maxItems, confirm, globalRulesAckKey }) => {
1593
1639
  validateTableName(tableName);
1640
+ const parsedItems = parseBulkItemsArg('items', items);
1641
+ assertMaxBulkItems('delete_records', parsedItems, maxItems);
1642
+ assertNoDuplicateBulkIds('delete_records', parsedItems);
1643
+ for (const [index, item] of parsedItems.entries()) {
1644
+ if (!item.id)
1645
+ throw new Error(`items[${index}].id is required.`);
1646
+ }
1594
1647
  const primaryKey = await getPrimaryFieldName(tableName);
1595
1648
  if (!confirm) {
1596
- const query = new URLSearchParams({
1597
- filter: JSON.stringify({ [primaryKey]: { _eq: id } }),
1598
- limit: '1',
1599
- fields: primaryKey,
1600
- });
1601
- const preview = await fetchAPI(ENFYRA_API_URL, `/${tableName}?${query.toString()}`).catch((error) => ({ error: String(error?.message || error) }));
1649
+ const previews = [];
1650
+ for (const [index, item] of parsedItems.entries()) {
1651
+ const query = new URLSearchParams({
1652
+ filter: JSON.stringify({ [primaryKey]: { _eq: item.id } }),
1653
+ limit: '1',
1654
+ fields: primaryKey,
1655
+ });
1656
+ const preview = await fetchAPI(ENFYRA_API_URL, `/${tableName}?${query.toString()}`).catch((error) => ({ error: String(error?.message || error) }));
1657
+ previews.push({
1658
+ index,
1659
+ id: item.id,
1660
+ preview: preview?.data?.[0] || null,
1661
+ previewError: preview?.error,
1662
+ });
1663
+ }
1602
1664
  return { content: [{ type: 'text', text: JSON.stringify({
1603
- action: 'delete_record_preview',
1665
+ action: 'delete_records_preview',
1604
1666
  tableName,
1605
- id,
1606
1667
  primaryKey,
1607
- preview: preview?.data?.[0] || null,
1608
- previewError: preview?.error,
1668
+ requested: parsedItems.length,
1669
+ duplicateIdsRejected: true,
1609
1670
  destructive: true,
1610
- next: 'Call delete_record again with confirm=true to delete this route-backed record.',
1671
+ previews,
1672
+ next: 'Call delete_records again with the same items and confirm=true to delete these route-backed records sequentially.',
1611
1673
  }, null, 2) }] };
1612
1674
  }
1613
1675
  assertGlobalRulesAck(globalRulesAckKey);
1614
- const query = parseQueryParamsArg(queryParams);
1615
- const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}/${id}`, query), { method: 'DELETE' });
1676
+ const deleted = [];
1677
+ for (const [index, item] of parsedItems.entries()) {
1678
+ const query = parseQueryParamsArg(JSON.stringify(item.queryParams || {}));
1679
+ const result = await fetchAPI(ENFYRA_API_URL, appendQuery(`/${tableName}/${encodeURIComponent(String(item.id))}`, query), { method: 'DELETE' });
1680
+ deleted.push({
1681
+ index,
1682
+ id: item.id,
1683
+ statusCode: result?.statusCode,
1684
+ success: result?.success,
1685
+ });
1686
+ }
1616
1687
  return { content: [{ type: 'text', text: JSON.stringify({
1617
- action: 'deleted',
1688
+ action: 'deleted_records',
1618
1689
  tableName,
1619
- id,
1620
- statusCode: result?.statusCode,
1621
- success: result?.success,
1690
+ requested: parsedItems.length,
1691
+ deletedCount: deleted.length,
1692
+ sequential: true,
1693
+ duplicateIdsRejected: true,
1694
+ deleted,
1622
1695
  }, null, 2) }] };
1623
1696
  });
1624
1697
  server.tool('list_methods', 'List enfyra_method records with their UI colors. Use this before creating route methods or method-colored UI.', {}, async () => {
@@ -1636,7 +1709,7 @@ server.tool('list_methods', 'List enfyra_method records with their UI colors. Us
1636
1709
  appUi: '/settings/methods',
1637
1710
  }, null, 2) }] };
1638
1711
  });
1639
- server.tool('create_method', 'Create a enfyra_method record with app badge colors. Prefer this over generic create_record for enfyra_method.', {
1712
+ server.tool('create_method', 'Create a enfyra_method record with app badge colors. Prefer this over generic create_records for enfyra_method.', {
1640
1713
  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.'),
1641
1714
  buttonColor: z.string().describe('Badge background color as full hex, e.g. #dbeafe.'),
1642
1715
  textColor: z.string().describe('Badge text color as full hex, e.g. #1d4ed8.'),
@@ -1666,7 +1739,7 @@ server.tool('create_method', 'Create a enfyra_method record with app badge color
1666
1739
  appUi: '/settings/methods',
1667
1740
  }, null, 2) }] };
1668
1741
  });
1669
- server.tool('update_method', 'Update a enfyra_method record color pair, and optionally rename non-system methods. Prefer this over generic update_record for enfyra_method.', {
1742
+ 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.', {
1670
1743
  id: z.string().optional().describe('Method record id. If omitted, method is used to find the record.'),
1671
1744
  method: z.string().optional().describe('Existing method name to find, or new name when id is provided.'),
1672
1745
  buttonColor: z.string().optional().describe('Badge background color as full hex, e.g. #dbeafe.'),
@@ -2250,10 +2323,10 @@ server.tool('get_all_routes', 'List route definitions with minimal fields. Every
2250
2323
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
2251
2324
  });
2252
2325
  server.tool('create_route', [
2253
- '**Use this when the user wants a new REST API route or path** — not `create_table`. Custom routes must omit `mainTableId`.',
2326
+ '**Use this when the user wants a new REST API route or path** — not `create_tables`. Custom routes must omit `mainTableId`.',
2254
2327
  '`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.',
2255
2328
  '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`.',
2256
- 'availableMethods = which REST verbs the route responds to. publicMethods = which REST verbs are public (no auth). GraphQL is enabled separately through enfyra_graphql/update_table graphqlEnabled.',
2329
+ '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.',
2257
2330
  'After creation the tool auto-reloads routes. Then create handlers for specific methods via create_handler on this route id.',
2258
2331
  'Flow: create_route → create_handler (per method) → optionally create_pre_hook / create_post_hook → test via HTTP or admin test APIs (see server instructions).',
2259
2332
  ].join(' '), {