@centrali-io/centrali-mcp 4.4.8 → 4.4.9

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 CHANGED
@@ -36,6 +36,23 @@ Add to your MCP client configuration (e.g., Claude Desktop, Cursor):
36
36
  | `CENTRALI_CLIENT_SECRET` | Yes | Service account client secret |
37
37
  | `CENTRALI_WORKSPACE` | Yes | Workspace slug to operate in |
38
38
 
39
+ ### Service Account Permissions
40
+
41
+ A freshly created service account **has no permissions by default**. You must assign it a role before the MCP server can do anything useful, otherwise every tool call will return `403 Forbidden`.
42
+
43
+ **Quickest setup — full admin access:**
44
+
45
+ 1. In the Console, go to **Settings → Service Accounts**
46
+ 2. Create your service account and save the client secret
47
+ 3. Open the service account, go to the **Groups** tab
48
+ 4. Add it to the **workspace_administrators** group
49
+
50
+ This gives the MCP server full access to all workspace resources.
51
+
52
+ **Least-privilege setup — custom permissions:**
53
+
54
+ If you only want the MCP to manage specific resources (e.g., collections and records but not billing), create a custom group with a policy scoped to the resources you need, then add the service account to that group. See the [Policies and Permissions guide](https://docs.centrali.io/authentication/policies-and-permissions/) for details.
55
+
39
56
  ## Getting Started
40
57
 
41
58
  After connecting, call `describe_centrali` first — it returns the full capability map, feature matrix, and SDK integration guidance. Then use the specific `describe_*` tools for deeper schema details on any domain.
package/dist/index.js CHANGED
@@ -53,7 +53,7 @@ function main() {
53
53
  // Register all tools
54
54
  (0, structures_js_1.registerCollectionTools)(server, sdk, baseUrl, workspaceId);
55
55
  (0, structures_js_1.registerStructureTools)(server, sdk);
56
- (0, records_js_1.registerRecordTools)(server, sdk);
56
+ (0, records_js_1.registerRecordTools)(server, sdk, baseUrl, workspaceId);
57
57
  (0, search_js_1.registerSearchTools)(server, sdk);
58
58
  (0, compute_js_1.registerComputeTools)(server, sdk, baseUrl, workspaceId);
59
59
  (0, smart_queries_js_1.registerSmartQueryTools)(server, sdk);
@@ -735,4 +735,171 @@ function registerComputeTools(server, sdk, centraliUrl, workspaceId) {
735
735
  };
736
736
  }
737
737
  }));
738
+ // ── Scaffold (function + trigger in one step) ─────────────────────
739
+ server.tool("scaffold_function", "Create a compute function with boilerplate code and optionally wire a trigger — all in one step. The function uses the required `async function run()` pattern with `api.*` globals, `triggerParams`, and `executionParams` available.", {
740
+ name: zod_1.z.string().describe("Function name"),
741
+ description: zod_1.z.string().optional().describe("Function description"),
742
+ code: zod_1.z.string().describe("Function code. Must use `async function run() { ... }` pattern. Has access to `api` (data operations), `triggerParams` (static config), and `executionParams` (runtime parameters)."),
743
+ trigger: zod_1.z.object({
744
+ type: zod_1.z.enum(["on_demand", "record", "schedule", "endpoint"]).describe("Trigger type"),
745
+ name: zod_1.z.string().optional().describe("Trigger name (auto-generated if omitted)"),
746
+ description: zod_1.z.string().optional(),
747
+ // Record trigger fields
748
+ recordSlug: zod_1.z.string().optional().describe("Collection slug (required for record triggers)"),
749
+ event: zod_1.z.string().optional().describe("Record event: afterCreate, afterUpdate, afterDelete (required for record triggers)"),
750
+ // Schedule trigger fields
751
+ cronExpression: zod_1.z.string().optional().describe("Cron expression (required for schedule triggers)"),
752
+ // Endpoint trigger fields
753
+ endpointPath: zod_1.z.string().optional().describe("URL path segment (required for endpoint triggers)"),
754
+ // Common
755
+ params: zod_1.z.record(zod_1.z.string(), zod_1.z.any()).optional().describe("Static trigger parameters available as triggerParams in the function"),
756
+ }).optional().describe("Trigger configuration. If omitted, creates function without a trigger."),
757
+ }, (_a) => __awaiter(this, [_a], void 0, function* ({ name, description, code, trigger }) {
758
+ try {
759
+ // Validate code pattern
760
+ if (/module\.exports\s*=/.test(code)) {
761
+ return {
762
+ content: [
763
+ {
764
+ type: "text",
765
+ text: "Error: Do not use module.exports. Functions must define 'async function run() { ... }'. Globals available: api, triggerParams, executionParams.",
766
+ },
767
+ ],
768
+ isError: true,
769
+ };
770
+ }
771
+ // Step 1: Create the function
772
+ const fnInput = { name, code };
773
+ if (description !== undefined)
774
+ fnInput.description = description;
775
+ const fnResult = yield sdk.functions.create(fnInput);
776
+ const fn = fnResult.data;
777
+ const fnId = fn.id;
778
+ // If no trigger requested, return function only
779
+ if (!trigger) {
780
+ return {
781
+ content: [
782
+ {
783
+ type: "text",
784
+ text: [
785
+ `Function created:`,
786
+ ` ID: ${fnId}`,
787
+ ` Name: ${name}`,
788
+ ``,
789
+ `No trigger requested. Use create_trigger to wire one later.`,
790
+ ].join("\n"),
791
+ },
792
+ ],
793
+ };
794
+ }
795
+ // Step 2: Build trigger input from the simplified config
796
+ const typeMap = {
797
+ on_demand: "on-demand",
798
+ record: "event-driven",
799
+ schedule: "scheduled",
800
+ endpoint: "endpoint",
801
+ };
802
+ const executionType = typeMap[trigger.type];
803
+ const triggerName = trigger.name || `${name} trigger`;
804
+ const triggerInput = {
805
+ name: triggerName,
806
+ functionId: fnId,
807
+ executionType,
808
+ };
809
+ if (trigger.description !== undefined)
810
+ triggerInput.description = trigger.description;
811
+ // Build triggerMetadata based on type
812
+ const meta = {};
813
+ if (trigger.params)
814
+ meta.params = trigger.params;
815
+ if (trigger.type === "record") {
816
+ if (trigger.recordSlug)
817
+ meta.recordSlug = trigger.recordSlug;
818
+ if (trigger.event)
819
+ meta.eventType = trigger.event;
820
+ }
821
+ else if (trigger.type === "schedule") {
822
+ meta.scheduleType = "cron";
823
+ if (trigger.cronExpression)
824
+ meta.cronExpression = trigger.cronExpression;
825
+ }
826
+ else if (trigger.type === "endpoint") {
827
+ if (trigger.endpointPath)
828
+ meta.path = trigger.endpointPath;
829
+ }
830
+ if (Object.keys(meta).length > 0)
831
+ triggerInput.triggerMetadata = meta;
832
+ // Step 3: Create the trigger
833
+ let triggerData;
834
+ try {
835
+ const triggerResult = yield sdk.triggers.create(triggerInput);
836
+ triggerData = triggerResult.data;
837
+ }
838
+ catch (triggerError) {
839
+ // Function succeeded but trigger failed — report both
840
+ return {
841
+ content: [
842
+ {
843
+ type: "text",
844
+ text: [
845
+ `Function created:`,
846
+ ` ID: ${fnId}`,
847
+ ` Name: ${name}`,
848
+ ``,
849
+ formatError(triggerError, "creating trigger"),
850
+ ``,
851
+ `The function was created successfully but the trigger failed. You can retry with create_trigger using functionId '${fnId}'.`,
852
+ ].join("\n"),
853
+ },
854
+ ],
855
+ };
856
+ }
857
+ // Build a human-friendly trigger type label
858
+ let typeLabel = trigger.type;
859
+ if (trigger.type === "record" && trigger.event && trigger.recordSlug) {
860
+ typeLabel = `record (${trigger.event} on ${trigger.recordSlug})`;
861
+ }
862
+ else if (trigger.type === "schedule" && trigger.cronExpression) {
863
+ typeLabel = `schedule (${trigger.cronExpression})`;
864
+ }
865
+ else if (trigger.type === "endpoint" && trigger.endpointPath) {
866
+ typeLabel = `endpoint (/${trigger.endpointPath})`;
867
+ }
868
+ const lines = [
869
+ `Function created:`,
870
+ ` ID: ${fnId}`,
871
+ ` Name: ${name}`,
872
+ ``,
873
+ `Trigger created:`,
874
+ ` ID: ${triggerData.id}`,
875
+ ` Type: ${typeLabel}`,
876
+ ``,
877
+ ];
878
+ if (trigger.type === "on_demand") {
879
+ lines.push(`Your function is ready. Invoke it via the trigger or use invoke_trigger with ID ${triggerData.id}.`);
880
+ }
881
+ else if (trigger.type === "endpoint") {
882
+ lines.push(`Your function is ready. Invoke it via invoke_endpoint with path '${trigger.endpointPath}' or use invoke_trigger with ID ${triggerData.id}.`);
883
+ }
884
+ else {
885
+ lines.push(`Your function is ready. Invoke it via the trigger or use invoke_trigger with ID ${triggerData.id}.`);
886
+ }
887
+ return {
888
+ content: [
889
+ { type: "text", text: lines.join("\n") },
890
+ ],
891
+ };
892
+ }
893
+ catch (error) {
894
+ return {
895
+ content: [
896
+ {
897
+ type: "text",
898
+ text: formatError(error, `scaffolding function '${name}'`),
899
+ },
900
+ ],
901
+ isError: true,
902
+ };
903
+ }
904
+ }));
738
905
  }
@@ -987,7 +987,7 @@ function registerDescribeTools(server) {
987
987
  domain: "Orchestrations",
988
988
  description: "Orchestrations are multi-step workflows that chain compute functions together. Each step executes a function and passes its output to the next step.",
989
989
  orchestration_shape: {
990
- id: "UUID",
990
+ id: "Prefixed xid (e.g. orch_ctb5h4l6n7m8p9q0r1s2)",
991
991
  name: "string",
992
992
  description: "string | null",
993
993
  status: "'draft' | 'active' | 'paused'",
@@ -1058,8 +1058,8 @@ function registerDescribeTools(server) {
1058
1058
  },
1059
1059
  },
1060
1060
  run_shape: {
1061
- id: "UUID",
1062
- orchestrationId: "UUID",
1061
+ id: "Prefixed xid (e.g. orun_ctb5h4l6n7m8p9q0r1s2)",
1062
+ orchestrationId: "Prefixed xid (e.g. orch_ctb5h4l6n7m8p9q0r1s2)",
1063
1063
  status: "'pending' | 'running' | 'waiting' | 'completed' | 'failed'",
1064
1064
  input: "object — the input data provided when the run was triggered",
1065
1065
  output: "object | null — final output after all steps complete",
@@ -1068,7 +1068,7 @@ function registerDescribeTools(server) {
1068
1068
  correlationId: "string | null — optional tracing ID",
1069
1069
  },
1070
1070
  run_step_shape: {
1071
- stepId: "UUID",
1071
+ stepId: "Prefixed xid (e.g. ostep_ctb5h4l6n7m8p9q0r1s2)",
1072
1072
  status: "'pending' | 'running' | 'completed' | 'failed' | 'skipped'",
1073
1073
  input: "object — input passed to this step",
1074
1074
  output: "object | null — output returned by this step",
@@ -1903,7 +1903,7 @@ function registerDescribeTools(server) {
1903
1903
  },
1904
1904
  "start-orchestration": {
1905
1905
  description: "Start an orchestration workflow run. Use for multi-step processes.",
1906
- targetRef: "Orchestration ID (UUID)",
1906
+ targetRef: "Orchestration ID (prefixed xid, e.g. orch_...)",
1907
1907
  typical_activation: "button",
1908
1908
  executionMode: "'fire-and-poll' recommended — orchestrations are long-running",
1909
1909
  },
@@ -1,3 +1,3 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { CentraliSDK } from "@centrali-io/centrali-sdk";
3
- export declare function registerRecordTools(server: McpServer, sdk: CentraliSDK): void;
3
+ export declare function registerRecordTools(server: McpServer, sdk: CentraliSDK, centraliUrl: string, workspaceId: string): void;
@@ -8,8 +8,12 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
8
8
  step((generator = generator.apply(thisArg, _arguments || [])).next());
9
9
  });
10
10
  };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
11
14
  Object.defineProperty(exports, "__esModule", { value: true });
12
15
  exports.registerRecordTools = registerRecordTools;
16
+ const axios_1 = __importDefault(require("axios"));
13
17
  const zod_1 = require("zod");
14
18
  function formatError(error, context) {
15
19
  var _a, _b;
@@ -33,7 +37,59 @@ function formatError(error, context) {
33
37
  }
34
38
  return `Error ${context}: ${error instanceof Error ? error.message : String(error)}`;
35
39
  }
36
- function registerRecordTools(server, sdk) {
40
+ /**
41
+ * Ensures the SDK has a valid token.
42
+ */
43
+ function ensureToken(sdk) {
44
+ return __awaiter(this, void 0, void 0, function* () {
45
+ let token = sdk.getToken();
46
+ if (token)
47
+ return token;
48
+ try {
49
+ yield sdk.queryRecords("__noop__", { limit: 1 });
50
+ }
51
+ catch ( /* token refresh side effect */_a) { /* token refresh side effect */ }
52
+ return sdk.getToken();
53
+ });
54
+ }
55
+ /**
56
+ * Creates an axios instance pointing at the data service records API for a given recordSlug.
57
+ */
58
+ function createRecordsClient(sdk, centraliUrl, workspaceId, recordSlug) {
59
+ const url = new URL(centraliUrl);
60
+ const hostname = url.hostname.startsWith("api.")
61
+ ? url.hostname
62
+ : `api.${url.hostname}`;
63
+ const baseURL = `${url.protocol}//${hostname}/data/workspace/${workspaceId}/api/v1/records/slug/${recordSlug}`;
64
+ const client = axios_1.default.create({ baseURL, proxy: false });
65
+ client.interceptors.request.use((config) => __awaiter(this, void 0, void 0, function* () {
66
+ const token = yield ensureToken(sdk);
67
+ if (token) {
68
+ config.headers.Authorization = `Bearer ${token}`;
69
+ }
70
+ return config;
71
+ }));
72
+ client.interceptors.response.use((response) => response, (error) => __awaiter(this, void 0, void 0, function* () {
73
+ var _a, _b;
74
+ const originalRequest = error.config;
75
+ const isAuthError = ((_a = error.response) === null || _a === void 0 ? void 0 : _a.status) === 401 || ((_b = error.response) === null || _b === void 0 ? void 0 : _b.status) === 403;
76
+ if (isAuthError && !originalRequest._hasRetried) {
77
+ originalRequest._hasRetried = true;
78
+ try {
79
+ yield sdk.queryRecords("__noop__", { limit: 1 });
80
+ }
81
+ catch ( /* token refresh side effect */_c) { /* token refresh side effect */ }
82
+ const token = sdk.getToken();
83
+ if (token) {
84
+ originalRequest.headers.Authorization = `Bearer ${token}`;
85
+ return client.request(originalRequest);
86
+ }
87
+ }
88
+ return Promise.reject(error);
89
+ }));
90
+ return client;
91
+ }
92
+ function registerRecordTools(server, sdk, centraliUrl, workspaceId) {
37
93
  server.tool("query_records", "Query records from a collection with optional filters, sorting, and pagination. Filters use 'data.' prefix for custom fields and bracket notation for operators (e.g., 'data.status': 'active', 'data.price[lte]': 100).", {
38
94
  recordSlug: zod_1.z
39
95
  .string()
@@ -268,4 +324,153 @@ function registerRecordTools(server, sdk) {
268
324
  };
269
325
  }
270
326
  }));
327
+ // ── Bulk Operations (1 aggregate event per operation) ───────────────
328
+ server.tool("bulk_create_records", `Bulk create multiple records in a collection. All records are created in a single transaction. Publishes ONE aggregate 'records_bulk_created' event (not one per record). Use this when downstream triggers should process all records together as a batch. Max 1000 records per call. For individual events per record, use batch_create_records instead.`, {
329
+ recordSlug: zod_1.z.string().describe("The collection's record slug (e.g., 'orders')"),
330
+ records: zod_1.z
331
+ .array(zod_1.z.record(zod_1.z.string(), zod_1.z.any()))
332
+ .describe("Array of record data objects to create (max 1000). Each object has field names as keys."),
333
+ }, (_a) => __awaiter(this, [_a], void 0, function* ({ recordSlug, records }) {
334
+ try {
335
+ const client = createRecordsClient(sdk, centraliUrl, workspaceId, recordSlug);
336
+ const result = yield client.post("/bulk", records);
337
+ return {
338
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
339
+ };
340
+ }
341
+ catch (error) {
342
+ return {
343
+ content: [{ type: "text", text: formatError(error, `bulk creating records in '${recordSlug}'`) }],
344
+ isError: true,
345
+ };
346
+ }
347
+ }));
348
+ server.tool("bulk_update_records", `Bulk update multiple records with the same data. All records are updated in a single transaction. Publishes ONE aggregate 'records_bulk_updated' event (not one per record). Use this when all records need the same change and downstream triggers should process them together. For different data per record or individual events, use batch_update_records instead.`, {
349
+ recordSlug: zod_1.z.string().describe("The collection's record slug"),
350
+ ids: zod_1.z.array(zod_1.z.string()).describe("Array of record IDs (UUIDs) to update"),
351
+ data: zod_1.z
352
+ .record(zod_1.z.string(), zod_1.z.any())
353
+ .describe("Object with fields to set on ALL specified records (same data applied to each)"),
354
+ }, (_a) => __awaiter(this, [_a], void 0, function* ({ recordSlug, ids, data }) {
355
+ try {
356
+ const client = createRecordsClient(sdk, centraliUrl, workspaceId, recordSlug);
357
+ const result = yield client.patch("/bulk", { ids, data });
358
+ return {
359
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
360
+ };
361
+ }
362
+ catch (error) {
363
+ return {
364
+ content: [{ type: "text", text: formatError(error, `bulk updating records in '${recordSlug}'`) }],
365
+ isError: true,
366
+ };
367
+ }
368
+ }));
369
+ server.tool("bulk_delete_records", `Bulk delete multiple records. All records are deleted in a single transaction. Publishes ONE aggregate 'records_bulk_deleted' event (not one per record). Use this when downstream triggers should process all deletions together. For individual events per deletion, use batch_delete_records instead.`, {
370
+ recordSlug: zod_1.z.string().describe("The collection's record slug"),
371
+ ids: zod_1.z.array(zod_1.z.string()).describe("Array of record IDs (UUIDs) to delete"),
372
+ hard: zod_1.z
373
+ .boolean()
374
+ .optional()
375
+ .describe("If true, permanently delete. Default: false (soft delete, can be restored)"),
376
+ }, (_a) => __awaiter(this, [_a], void 0, function* ({ recordSlug, ids, hard }) {
377
+ try {
378
+ const client = createRecordsClient(sdk, centraliUrl, workspaceId, recordSlug);
379
+ const body = { ids };
380
+ if (hard !== undefined)
381
+ body.hard = hard;
382
+ const result = yield client.delete("/bulk", { data: body });
383
+ const deleteType = hard ? "permanently deleted" : "soft-deleted";
384
+ return {
385
+ content: [
386
+ {
387
+ type: "text",
388
+ text: `${ids.length} records ${deleteType} from '${recordSlug}'.\n\n${JSON.stringify(result.data, null, 2)}`,
389
+ },
390
+ ],
391
+ };
392
+ }
393
+ catch (error) {
394
+ return {
395
+ content: [{ type: "text", text: formatError(error, `bulk deleting records from '${recordSlug}'`) }],
396
+ isError: true,
397
+ };
398
+ }
399
+ }));
400
+ // ── Batch Operations (1 event per record) ──────────────────────────
401
+ server.tool("batch_create_records", `Create multiple records individually. Each record gets its own 'record_created' event. Use this when downstream triggers (functions, webhooks, orchestrations) should process each record separately. Supports partial failure — some records can succeed even if others fail. For a single aggregate event, use bulk_create_records instead.`, {
402
+ recordSlug: zod_1.z.string().describe("The collection's record slug (e.g., 'orders')"),
403
+ records: zod_1.z
404
+ .array(zod_1.z.record(zod_1.z.string(), zod_1.z.any()))
405
+ .describe("Array of record data objects to create. Each object has field names as keys."),
406
+ stopOnError: zod_1.z
407
+ .boolean()
408
+ .optional()
409
+ .describe("If true, stop processing on the first error. Default: false (continue and report failures)"),
410
+ }, (_a) => __awaiter(this, [_a], void 0, function* ({ recordSlug, records, stopOnError }) {
411
+ try {
412
+ const client = createRecordsClient(sdk, centraliUrl, workspaceId, recordSlug);
413
+ const params = {};
414
+ if (stopOnError !== undefined)
415
+ params.stopOnError = stopOnError;
416
+ const result = yield client.post("/batch", records, { params });
417
+ return {
418
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
419
+ };
420
+ }
421
+ catch (error) {
422
+ return {
423
+ content: [{ type: "text", text: formatError(error, `batch creating records in '${recordSlug}'`) }],
424
+ isError: true,
425
+ };
426
+ }
427
+ }));
428
+ server.tool("batch_update_records", `Update multiple records individually with different data per record. Each record gets its own 'record_updated' event. Use this when each record needs different changes and downstream triggers should process each update separately. For the same change applied to all records with a single event, use bulk_update_records instead.`, {
429
+ recordSlug: zod_1.z.string().describe("The collection's record slug"),
430
+ updates: zod_1.z
431
+ .array(zod_1.z.object({
432
+ id: zod_1.z.string().describe("The record ID (UUID) to update"),
433
+ data: zod_1.z
434
+ .record(zod_1.z.string(), zod_1.z.any())
435
+ .describe("Object with fields to update for this specific record"),
436
+ }))
437
+ .describe("Array of update objects, each with an 'id' and 'data' to apply to that record"),
438
+ }, (_a) => __awaiter(this, [_a], void 0, function* ({ recordSlug, updates }) {
439
+ try {
440
+ const client = createRecordsClient(sdk, centraliUrl, workspaceId, recordSlug);
441
+ const result = yield client.patch("/batch", updates);
442
+ return {
443
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
444
+ };
445
+ }
446
+ catch (error) {
447
+ return {
448
+ content: [{ type: "text", text: formatError(error, `batch updating records in '${recordSlug}'`) }],
449
+ isError: true,
450
+ };
451
+ }
452
+ }));
453
+ server.tool("batch_delete_records", `Delete multiple records individually. Each record gets its own 'record_deleted' event. Use this when downstream triggers should process each deletion separately. For a single aggregate event, use bulk_delete_records instead.`, {
454
+ recordSlug: zod_1.z.string().describe("The collection's record slug"),
455
+ ids: zod_1.z.array(zod_1.z.string()).describe("Array of record IDs (UUIDs) to delete"),
456
+ }, (_a) => __awaiter(this, [_a], void 0, function* ({ recordSlug, ids }) {
457
+ try {
458
+ const client = createRecordsClient(sdk, centraliUrl, workspaceId, recordSlug);
459
+ const result = yield client.delete("/batch", { data: { ids } });
460
+ return {
461
+ content: [
462
+ {
463
+ type: "text",
464
+ text: `${ids.length} records batch-deleted from '${recordSlug}'.\n\n${JSON.stringify(result.data, null, 2)}`,
465
+ },
466
+ ],
467
+ };
468
+ }
469
+ catch (error) {
470
+ return {
471
+ content: [{ type: "text", text: formatError(error, `batch deleting records from '${recordSlug}'`) }],
472
+ isError: true,
473
+ };
474
+ }
475
+ }));
271
476
  }
@@ -500,4 +500,143 @@ Each fix is an object with: { fieldName, fixType, value?, expression?, applyToAl
500
500
  };
501
501
  }
502
502
  }));
503
+ server.tool("explore_collections", "Get a compact overview of ALL collections in the workspace with their field names, types, and constraints. Returns everything in one call — no need to list collections and then get each one separately.", {
504
+ includeRecordCounts: zod_1.z.boolean().optional().describe("Include record counts for each collection (slower, requires an extra query per collection). Defaults to false."),
505
+ }, (_a) => __awaiter(this, [_a], void 0, function* ({ includeRecordCounts }) {
506
+ var _b, _c, _d, _e;
507
+ try {
508
+ // Fetch all collections (paginate to get everything)
509
+ const firstPage = yield sdk.collections.list({ page: 1, limit: 100 });
510
+ let allCollections = (_b = firstPage.data) !== null && _b !== void 0 ? _b : [];
511
+ const meta = firstPage.meta;
512
+ if (meta && meta.total > allCollections.length) {
513
+ const totalPages = Math.ceil(meta.total / 100);
514
+ for (let page = 2; page <= totalPages; page++) {
515
+ const nextPage = yield sdk.collections.list({ page, limit: 100 });
516
+ allCollections = allCollections.concat((_c = nextPage.data) !== null && _c !== void 0 ? _c : []);
517
+ }
518
+ }
519
+ if (allCollections.length === 0) {
520
+ return {
521
+ content: [{ type: "text", text: "No collections found in this workspace." }],
522
+ };
523
+ }
524
+ // Fetch full schema for each collection
525
+ const details = yield Promise.all(allCollections.map((col) => __awaiter(this, void 0, void 0, function* () {
526
+ var _a, _b, _c;
527
+ try {
528
+ const full = yield sdk.collections.getBySlug(col.recordSlug);
529
+ const detail = Object.assign({}, ((_a = full.data) !== null && _a !== void 0 ? _a : col));
530
+ if (includeRecordCounts) {
531
+ try {
532
+ const records = yield sdk.queryRecords(col.recordSlug, { pageSize: 1 });
533
+ detail._recordCount = (_c = (_b = records.meta) === null || _b === void 0 ? void 0 : _b.total) !== null && _c !== void 0 ? _c : "unknown";
534
+ }
535
+ catch (_d) {
536
+ detail._recordCount = "unknown";
537
+ }
538
+ }
539
+ return detail;
540
+ }
541
+ catch (_e) {
542
+ return Object.assign(Object.assign({}, col), { _fetchError: true });
543
+ }
544
+ })));
545
+ // Format as compact readable text
546
+ const lines = [];
547
+ for (const col of details) {
548
+ lines.push(`=== Collection: ${col.name} (slug: ${col.recordSlug}) ===`);
549
+ if (col.description) {
550
+ lines.push(`Description: ${col.description}`);
551
+ }
552
+ lines.push(`Schema mode: ${(_d = col.schemaDiscoveryMode) !== null && _d !== void 0 ? _d : "strict"}`);
553
+ if (col.tags && col.tags.length > 0) {
554
+ lines.push(`Tags: ${col.tags.join(", ")}`);
555
+ }
556
+ if (col._fetchError) {
557
+ lines.push("Fields: (unable to fetch full schema)");
558
+ }
559
+ else if (col.properties && col.properties.length > 0) {
560
+ lines.push("Fields:");
561
+ for (const prop of col.properties) {
562
+ const parts = [];
563
+ // Base type
564
+ let typeStr = prop.type;
565
+ if (prop.type === "string" && prop.renderAs) {
566
+ typeStr = prop.renderAs;
567
+ }
568
+ if (prop.type === "array" && ((_e = prop.items) === null || _e === void 0 ? void 0 : _e.type)) {
569
+ typeStr = `array of ${prop.items.type}`;
570
+ }
571
+ if (prop.type === "reference" && prop.target) {
572
+ typeStr = `reference -> ${prop.target}`;
573
+ if (prop.relationship)
574
+ typeStr += ` (${prop.relationship})`;
575
+ }
576
+ // Enum / select values
577
+ if (prop.enum && prop.enum.length > 0) {
578
+ const vals = prop.enum.map(String);
579
+ if (vals.length <= 8) {
580
+ typeStr = `select: ${vals.join("|")}`;
581
+ }
582
+ else {
583
+ typeStr = `select: ${vals.slice(0, 6).join("|")}|... (${vals.length} values)`;
584
+ }
585
+ }
586
+ parts.push(typeStr);
587
+ // Constraints
588
+ if (prop.required)
589
+ parts.push("required");
590
+ if (prop.isUnique)
591
+ parts.push("unique");
592
+ if (prop.immutable)
593
+ parts.push("immutable");
594
+ if (prop.isSecret)
595
+ parts.push("secret");
596
+ if (prop.nullable)
597
+ parts.push("nullable");
598
+ if (prop.default !== undefined)
599
+ parts.push(`default: ${JSON.stringify(prop.default)}`);
600
+ if (prop.minimum !== undefined)
601
+ parts.push(`min: ${prop.minimum}`);
602
+ if (prop.maximum !== undefined)
603
+ parts.push(`max: ${prop.maximum}`);
604
+ if (prop.minLength !== undefined)
605
+ parts.push(`minLen: ${prop.minLength}`);
606
+ if (prop.maxLength !== undefined)
607
+ parts.push(`maxLen: ${prop.maxLength}`);
608
+ if (prop.pattern)
609
+ parts.push(`pattern: ${prop.pattern}`);
610
+ if (prop.expression)
611
+ parts.push("computed");
612
+ if (prop.autoIncrement)
613
+ parts.push("auto-increment");
614
+ lines.push(` - ${prop.name} (${parts.join(", ")})`);
615
+ }
616
+ }
617
+ else {
618
+ lines.push("Fields: (none)");
619
+ }
620
+ if (includeRecordCounts && col._recordCount !== undefined) {
621
+ lines.push(`Records: ${col._recordCount}`);
622
+ }
623
+ lines.push("");
624
+ }
625
+ lines.push(`Total: ${details.length} collection(s)`);
626
+ return {
627
+ content: [{ type: "text", text: lines.join("\n") }],
628
+ };
629
+ }
630
+ catch (error) {
631
+ return {
632
+ content: [
633
+ {
634
+ type: "text",
635
+ text: formatError(error, "exploring collections"),
636
+ },
637
+ ],
638
+ isError: true,
639
+ };
640
+ }
641
+ }));
503
642
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@centrali-io/centrali-mcp",
3
- "version": "4.4.8",
3
+ "version": "4.4.9",
4
4
  "description": "Centrali MCP Server - AI assistant integration for Centrali workspaces",
5
5
  "main": "dist/index.js",
6
6
  "type": "commonjs",
package/src/index.ts CHANGED
@@ -47,7 +47,7 @@ async function main() {
47
47
  // Register all tools
48
48
  registerCollectionTools(server, sdk, baseUrl, workspaceId);
49
49
  registerStructureTools(server, sdk);
50
- registerRecordTools(server, sdk);
50
+ registerRecordTools(server, sdk, baseUrl, workspaceId);
51
51
  registerSearchTools(server, sdk);
52
52
  registerComputeTools(server, sdk, baseUrl, workspaceId);
53
53
  registerSmartQueryTools(server, sdk);
@@ -828,4 +828,176 @@ export function registerComputeTools(server: McpServer, sdk: CentraliSDK, centra
828
828
  }
829
829
  }
830
830
  );
831
+
832
+ // ── Scaffold (function + trigger in one step) ─────────────────────
833
+
834
+ server.tool(
835
+ "scaffold_function",
836
+ "Create a compute function with boilerplate code and optionally wire a trigger — all in one step. The function uses the required `async function run()` pattern with `api.*` globals, `triggerParams`, and `executionParams` available.",
837
+ {
838
+ name: z.string().describe("Function name"),
839
+ description: z.string().optional().describe("Function description"),
840
+ code: z.string().describe("Function code. Must use `async function run() { ... }` pattern. Has access to `api` (data operations), `triggerParams` (static config), and `executionParams` (runtime parameters)."),
841
+ trigger: z.object({
842
+ type: z.enum(["on_demand", "record", "schedule", "endpoint"]).describe("Trigger type"),
843
+ name: z.string().optional().describe("Trigger name (auto-generated if omitted)"),
844
+ description: z.string().optional(),
845
+ // Record trigger fields
846
+ recordSlug: z.string().optional().describe("Collection slug (required for record triggers)"),
847
+ event: z.string().optional().describe("Record event: afterCreate, afterUpdate, afterDelete (required for record triggers)"),
848
+ // Schedule trigger fields
849
+ cronExpression: z.string().optional().describe("Cron expression (required for schedule triggers)"),
850
+ // Endpoint trigger fields
851
+ endpointPath: z.string().optional().describe("URL path segment (required for endpoint triggers)"),
852
+ // Common
853
+ params: z.record(z.string(), z.any()).optional().describe("Static trigger parameters available as triggerParams in the function"),
854
+ }).optional().describe("Trigger configuration. If omitted, creates function without a trigger."),
855
+ },
856
+ async ({ name, description, code, trigger }) => {
857
+ try {
858
+ // Validate code pattern
859
+ if (/module\.exports\s*=/.test(code)) {
860
+ return {
861
+ content: [
862
+ {
863
+ type: "text",
864
+ text: "Error: Do not use module.exports. Functions must define 'async function run() { ... }'. Globals available: api, triggerParams, executionParams.",
865
+ },
866
+ ],
867
+ isError: true,
868
+ };
869
+ }
870
+
871
+ // Step 1: Create the function
872
+ const fnInput: Record<string, any> = { name, code };
873
+ if (description !== undefined) fnInput.description = description;
874
+
875
+ const fnResult = await sdk.functions.create(fnInput as any);
876
+ const fn = fnResult.data;
877
+ const fnId = fn.id;
878
+
879
+ // If no trigger requested, return function only
880
+ if (!trigger) {
881
+ return {
882
+ content: [
883
+ {
884
+ type: "text",
885
+ text: [
886
+ `Function created:`,
887
+ ` ID: ${fnId}`,
888
+ ` Name: ${name}`,
889
+ ``,
890
+ `No trigger requested. Use create_trigger to wire one later.`,
891
+ ].join("\n"),
892
+ },
893
+ ],
894
+ };
895
+ }
896
+
897
+ // Step 2: Build trigger input from the simplified config
898
+ const typeMap: Record<string, string> = {
899
+ on_demand: "on-demand",
900
+ record: "event-driven",
901
+ schedule: "scheduled",
902
+ endpoint: "endpoint",
903
+ };
904
+
905
+ const executionType = typeMap[trigger.type];
906
+ const triggerName = trigger.name || `${name} trigger`;
907
+
908
+ const triggerInput: Record<string, any> = {
909
+ name: triggerName,
910
+ functionId: fnId,
911
+ executionType,
912
+ };
913
+ if (trigger.description !== undefined) triggerInput.description = trigger.description;
914
+
915
+ // Build triggerMetadata based on type
916
+ const meta: Record<string, any> = {};
917
+ if (trigger.params) meta.params = trigger.params;
918
+
919
+ if (trigger.type === "record") {
920
+ if (trigger.recordSlug) meta.recordSlug = trigger.recordSlug;
921
+ if (trigger.event) meta.eventType = trigger.event;
922
+ } else if (trigger.type === "schedule") {
923
+ meta.scheduleType = "cron";
924
+ if (trigger.cronExpression) meta.cronExpression = trigger.cronExpression;
925
+ } else if (trigger.type === "endpoint") {
926
+ if (trigger.endpointPath) meta.path = trigger.endpointPath;
927
+ }
928
+
929
+ if (Object.keys(meta).length > 0) triggerInput.triggerMetadata = meta;
930
+
931
+ // Step 3: Create the trigger
932
+ let triggerData: any;
933
+ try {
934
+ const triggerResult = await sdk.triggers.create(triggerInput as any);
935
+ triggerData = triggerResult.data;
936
+ } catch (triggerError: unknown) {
937
+ // Function succeeded but trigger failed — report both
938
+ return {
939
+ content: [
940
+ {
941
+ type: "text",
942
+ text: [
943
+ `Function created:`,
944
+ ` ID: ${fnId}`,
945
+ ` Name: ${name}`,
946
+ ``,
947
+ formatError(triggerError, "creating trigger"),
948
+ ``,
949
+ `The function was created successfully but the trigger failed. You can retry with create_trigger using functionId '${fnId}'.`,
950
+ ].join("\n"),
951
+ },
952
+ ],
953
+ };
954
+ }
955
+
956
+ // Build a human-friendly trigger type label
957
+ let typeLabel = trigger.type as string;
958
+ if (trigger.type === "record" && trigger.event && trigger.recordSlug) {
959
+ typeLabel = `record (${trigger.event} on ${trigger.recordSlug})`;
960
+ } else if (trigger.type === "schedule" && trigger.cronExpression) {
961
+ typeLabel = `schedule (${trigger.cronExpression})`;
962
+ } else if (trigger.type === "endpoint" && trigger.endpointPath) {
963
+ typeLabel = `endpoint (/${trigger.endpointPath})`;
964
+ }
965
+
966
+ const lines = [
967
+ `Function created:`,
968
+ ` ID: ${fnId}`,
969
+ ` Name: ${name}`,
970
+ ``,
971
+ `Trigger created:`,
972
+ ` ID: ${triggerData.id}`,
973
+ ` Type: ${typeLabel}`,
974
+ ``,
975
+ ];
976
+
977
+ if (trigger.type === "on_demand") {
978
+ lines.push(`Your function is ready. Invoke it via the trigger or use invoke_trigger with ID ${triggerData.id}.`);
979
+ } else if (trigger.type === "endpoint") {
980
+ lines.push(`Your function is ready. Invoke it via invoke_endpoint with path '${trigger.endpointPath}' or use invoke_trigger with ID ${triggerData.id}.`);
981
+ } else {
982
+ lines.push(`Your function is ready. Invoke it via the trigger or use invoke_trigger with ID ${triggerData.id}.`);
983
+ }
984
+
985
+ return {
986
+ content: [
987
+ { type: "text", text: lines.join("\n") },
988
+ ],
989
+ };
990
+ } catch (error: unknown) {
991
+ return {
992
+ content: [
993
+ {
994
+ type: "text",
995
+ text: formatError(error, `scaffolding function '${name}'`),
996
+ },
997
+ ],
998
+ isError: true,
999
+ };
1000
+ }
1001
+ }
1002
+ );
831
1003
  }
@@ -1083,7 +1083,7 @@ export function registerDescribeTools(server: McpServer) {
1083
1083
  description:
1084
1084
  "Orchestrations are multi-step workflows that chain compute functions together. Each step executes a function and passes its output to the next step.",
1085
1085
  orchestration_shape: {
1086
- id: "UUID",
1086
+ id: "Prefixed xid (e.g. orch_ctb5h4l6n7m8p9q0r1s2)",
1087
1087
  name: "string",
1088
1088
  description: "string | null",
1089
1089
  status: "'draft' | 'active' | 'paused'",
@@ -1154,8 +1154,8 @@ export function registerDescribeTools(server: McpServer) {
1154
1154
  },
1155
1155
  },
1156
1156
  run_shape: {
1157
- id: "UUID",
1158
- orchestrationId: "UUID",
1157
+ id: "Prefixed xid (e.g. orun_ctb5h4l6n7m8p9q0r1s2)",
1158
+ orchestrationId: "Prefixed xid (e.g. orch_ctb5h4l6n7m8p9q0r1s2)",
1159
1159
  status: "'pending' | 'running' | 'waiting' | 'completed' | 'failed'",
1160
1160
  input: "object — the input data provided when the run was triggered",
1161
1161
  output: "object | null — final output after all steps complete",
@@ -1164,7 +1164,7 @@ export function registerDescribeTools(server: McpServer) {
1164
1164
  correlationId: "string | null — optional tracing ID",
1165
1165
  },
1166
1166
  run_step_shape: {
1167
- stepId: "UUID",
1167
+ stepId: "Prefixed xid (e.g. ostep_ctb5h4l6n7m8p9q0r1s2)",
1168
1168
  status: "'pending' | 'running' | 'completed' | 'failed' | 'skipped'",
1169
1169
  input: "object — input passed to this step",
1170
1170
  output: "object | null — output returned by this step",
@@ -2152,7 +2152,7 @@ export function registerDescribeTools(server: McpServer) {
2152
2152
  "start-orchestration": {
2153
2153
  description:
2154
2154
  "Start an orchestration workflow run. Use for multi-step processes.",
2155
- targetRef: "Orchestration ID (UUID)",
2155
+ targetRef: "Orchestration ID (prefixed xid, e.g. orch_...)",
2156
2156
  typical_activation: "button",
2157
2157
  executionMode:
2158
2158
  "'fire-and-poll' recommended — orchestrations are long-running",
@@ -1,5 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { CentraliSDK } from "@centrali-io/centrali-sdk";
3
+ import axios, { AxiosInstance } from "axios";
3
4
  import { z } from "zod";
4
5
 
5
6
  function formatError(error: unknown, context: string): string {
@@ -23,7 +24,64 @@ function formatError(error: unknown, context: string): string {
23
24
  return `Error ${context}: ${error instanceof Error ? error.message : String(error)}`;
24
25
  }
25
26
 
26
- export function registerRecordTools(server: McpServer, sdk: CentraliSDK) {
27
+ /**
28
+ * Ensures the SDK has a valid token.
29
+ */
30
+ async function ensureToken(sdk: CentraliSDK): Promise<string | null> {
31
+ let token = sdk.getToken();
32
+ if (token) return token;
33
+ try {
34
+ await sdk.queryRecords("__noop__", { limit: 1 });
35
+ } catch { /* token refresh side effect */ }
36
+ return sdk.getToken();
37
+ }
38
+
39
+ /**
40
+ * Creates an axios instance pointing at the data service records API for a given recordSlug.
41
+ */
42
+ function createRecordsClient(sdk: CentraliSDK, centraliUrl: string, workspaceId: string, recordSlug: string): AxiosInstance {
43
+ const url = new URL(centraliUrl);
44
+ const hostname = url.hostname.startsWith("api.")
45
+ ? url.hostname
46
+ : `api.${url.hostname}`;
47
+ const baseURL = `${url.protocol}//${hostname}/data/workspace/${workspaceId}/api/v1/records/slug/${recordSlug}`;
48
+
49
+ const client = axios.create({ baseURL, proxy: false });
50
+
51
+ client.interceptors.request.use(async (config) => {
52
+ const token = await ensureToken(sdk);
53
+ if (token) {
54
+ config.headers.Authorization = `Bearer ${token}`;
55
+ }
56
+ return config;
57
+ });
58
+
59
+ client.interceptors.response.use(
60
+ (response) => response,
61
+ async (error) => {
62
+ const originalRequest = error.config;
63
+ const isAuthError = error.response?.status === 401 || error.response?.status === 403;
64
+
65
+ if (isAuthError && !originalRequest._hasRetried) {
66
+ originalRequest._hasRetried = true;
67
+ try {
68
+ await sdk.queryRecords("__noop__", { limit: 1 });
69
+ } catch { /* token refresh side effect */ }
70
+
71
+ const token = sdk.getToken();
72
+ if (token) {
73
+ originalRequest.headers.Authorization = `Bearer ${token}`;
74
+ return client.request(originalRequest);
75
+ }
76
+ }
77
+ return Promise.reject(error);
78
+ }
79
+ );
80
+
81
+ return client;
82
+ }
83
+
84
+ export function registerRecordTools(server: McpServer, sdk: CentraliSDK, centraliUrl: string, workspaceId: string) {
27
85
  server.tool(
28
86
  "query_records",
29
87
  "Query records from a collection with optional filters, sorting, and pagination. Filters use 'data.' prefix for custom fields and bracket notation for operators (e.g., 'data.status': 'active', 'data.price[lte]': 100).",
@@ -315,4 +373,185 @@ export function registerRecordTools(server: McpServer, sdk: CentraliSDK) {
315
373
  }
316
374
  }
317
375
  );
376
+
377
+ // ── Bulk Operations (1 aggregate event per operation) ───────────────
378
+
379
+ server.tool(
380
+ "bulk_create_records",
381
+ `Bulk create multiple records in a collection. All records are created in a single transaction. Publishes ONE aggregate 'records_bulk_created' event (not one per record). Use this when downstream triggers should process all records together as a batch. Max 1000 records per call. For individual events per record, use batch_create_records instead.`,
382
+ {
383
+ recordSlug: z.string().describe("The collection's record slug (e.g., 'orders')"),
384
+ records: z
385
+ .array(z.record(z.string(), z.any()))
386
+ .describe("Array of record data objects to create (max 1000). Each object has field names as keys."),
387
+ },
388
+ async ({ recordSlug, records }) => {
389
+ try {
390
+ const client = createRecordsClient(sdk, centraliUrl, workspaceId, recordSlug);
391
+ const result = await client.post("/bulk", records);
392
+ return {
393
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
394
+ };
395
+ } catch (error: unknown) {
396
+ return {
397
+ content: [{ type: "text", text: formatError(error, `bulk creating records in '${recordSlug}'`) }],
398
+ isError: true,
399
+ };
400
+ }
401
+ }
402
+ );
403
+
404
+ server.tool(
405
+ "bulk_update_records",
406
+ `Bulk update multiple records with the same data. All records are updated in a single transaction. Publishes ONE aggregate 'records_bulk_updated' event (not one per record). Use this when all records need the same change and downstream triggers should process them together. For different data per record or individual events, use batch_update_records instead.`,
407
+ {
408
+ recordSlug: z.string().describe("The collection's record slug"),
409
+ ids: z.array(z.string()).describe("Array of record IDs (UUIDs) to update"),
410
+ data: z
411
+ .record(z.string(), z.any())
412
+ .describe("Object with fields to set on ALL specified records (same data applied to each)"),
413
+ },
414
+ async ({ recordSlug, ids, data }) => {
415
+ try {
416
+ const client = createRecordsClient(sdk, centraliUrl, workspaceId, recordSlug);
417
+ const result = await client.patch("/bulk", { ids, data });
418
+ return {
419
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
420
+ };
421
+ } catch (error: unknown) {
422
+ return {
423
+ content: [{ type: "text", text: formatError(error, `bulk updating records in '${recordSlug}'`) }],
424
+ isError: true,
425
+ };
426
+ }
427
+ }
428
+ );
429
+
430
+ server.tool(
431
+ "bulk_delete_records",
432
+ `Bulk delete multiple records. All records are deleted in a single transaction. Publishes ONE aggregate 'records_bulk_deleted' event (not one per record). Use this when downstream triggers should process all deletions together. For individual events per deletion, use batch_delete_records instead.`,
433
+ {
434
+ recordSlug: z.string().describe("The collection's record slug"),
435
+ ids: z.array(z.string()).describe("Array of record IDs (UUIDs) to delete"),
436
+ hard: z
437
+ .boolean()
438
+ .optional()
439
+ .describe("If true, permanently delete. Default: false (soft delete, can be restored)"),
440
+ },
441
+ async ({ recordSlug, ids, hard }) => {
442
+ try {
443
+ const client = createRecordsClient(sdk, centraliUrl, workspaceId, recordSlug);
444
+ const body: Record<string, any> = { ids };
445
+ if (hard !== undefined) body.hard = hard;
446
+ const result = await client.delete("/bulk", { data: body });
447
+ const deleteType = hard ? "permanently deleted" : "soft-deleted";
448
+ return {
449
+ content: [
450
+ {
451
+ type: "text",
452
+ text: `${ids.length} records ${deleteType} from '${recordSlug}'.\n\n${JSON.stringify(result.data, null, 2)}`,
453
+ },
454
+ ],
455
+ };
456
+ } catch (error: unknown) {
457
+ return {
458
+ content: [{ type: "text", text: formatError(error, `bulk deleting records from '${recordSlug}'`) }],
459
+ isError: true,
460
+ };
461
+ }
462
+ }
463
+ );
464
+
465
+ // ── Batch Operations (1 event per record) ──────────────────────────
466
+
467
+ server.tool(
468
+ "batch_create_records",
469
+ `Create multiple records individually. Each record gets its own 'record_created' event. Use this when downstream triggers (functions, webhooks, orchestrations) should process each record separately. Supports partial failure — some records can succeed even if others fail. For a single aggregate event, use bulk_create_records instead.`,
470
+ {
471
+ recordSlug: z.string().describe("The collection's record slug (e.g., 'orders')"),
472
+ records: z
473
+ .array(z.record(z.string(), z.any()))
474
+ .describe("Array of record data objects to create. Each object has field names as keys."),
475
+ stopOnError: z
476
+ .boolean()
477
+ .optional()
478
+ .describe("If true, stop processing on the first error. Default: false (continue and report failures)"),
479
+ },
480
+ async ({ recordSlug, records, stopOnError }) => {
481
+ try {
482
+ const client = createRecordsClient(sdk, centraliUrl, workspaceId, recordSlug);
483
+ const params: Record<string, any> = {};
484
+ if (stopOnError !== undefined) params.stopOnError = stopOnError;
485
+ const result = await client.post("/batch", records, { params });
486
+ return {
487
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
488
+ };
489
+ } catch (error: unknown) {
490
+ return {
491
+ content: [{ type: "text", text: formatError(error, `batch creating records in '${recordSlug}'`) }],
492
+ isError: true,
493
+ };
494
+ }
495
+ }
496
+ );
497
+
498
+ server.tool(
499
+ "batch_update_records",
500
+ `Update multiple records individually with different data per record. Each record gets its own 'record_updated' event. Use this when each record needs different changes and downstream triggers should process each update separately. For the same change applied to all records with a single event, use bulk_update_records instead.`,
501
+ {
502
+ recordSlug: z.string().describe("The collection's record slug"),
503
+ updates: z
504
+ .array(
505
+ z.object({
506
+ id: z.string().describe("The record ID (UUID) to update"),
507
+ data: z
508
+ .record(z.string(), z.any())
509
+ .describe("Object with fields to update for this specific record"),
510
+ })
511
+ )
512
+ .describe("Array of update objects, each with an 'id' and 'data' to apply to that record"),
513
+ },
514
+ async ({ recordSlug, updates }) => {
515
+ try {
516
+ const client = createRecordsClient(sdk, centraliUrl, workspaceId, recordSlug);
517
+ const result = await client.patch("/batch", updates);
518
+ return {
519
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
520
+ };
521
+ } catch (error: unknown) {
522
+ return {
523
+ content: [{ type: "text", text: formatError(error, `batch updating records in '${recordSlug}'`) }],
524
+ isError: true,
525
+ };
526
+ }
527
+ }
528
+ );
529
+
530
+ server.tool(
531
+ "batch_delete_records",
532
+ `Delete multiple records individually. Each record gets its own 'record_deleted' event. Use this when downstream triggers should process each deletion separately. For a single aggregate event, use bulk_delete_records instead.`,
533
+ {
534
+ recordSlug: z.string().describe("The collection's record slug"),
535
+ ids: z.array(z.string()).describe("Array of record IDs (UUIDs) to delete"),
536
+ },
537
+ async ({ recordSlug, ids }) => {
538
+ try {
539
+ const client = createRecordsClient(sdk, centraliUrl, workspaceId, recordSlug);
540
+ const result = await client.delete("/batch", { data: { ids } });
541
+ return {
542
+ content: [
543
+ {
544
+ type: "text",
545
+ text: `${ids.length} records batch-deleted from '${recordSlug}'.\n\n${JSON.stringify(result.data, null, 2)}`,
546
+ },
547
+ ],
548
+ };
549
+ } catch (error: unknown) {
550
+ return {
551
+ content: [{ type: "text", text: formatError(error, `batch deleting records from '${recordSlug}'`) }],
552
+ isError: true,
553
+ };
554
+ }
555
+ }
556
+ );
318
557
  }
@@ -540,4 +540,144 @@ Each fix is an object with: { fieldName, fixType, value?, expression?, applyToAl
540
540
  }
541
541
  }
542
542
  );
543
+
544
+ server.tool(
545
+ "explore_collections",
546
+ "Get a compact overview of ALL collections in the workspace with their field names, types, and constraints. Returns everything in one call — no need to list collections and then get each one separately.",
547
+ {
548
+ includeRecordCounts: z.boolean().optional().describe("Include record counts for each collection (slower, requires an extra query per collection). Defaults to false."),
549
+ },
550
+ async ({ includeRecordCounts }) => {
551
+ try {
552
+ // Fetch all collections (paginate to get everything)
553
+ const firstPage = await sdk.collections.list({ page: 1, limit: 100 });
554
+ let allCollections = firstPage.data ?? [];
555
+ const meta = (firstPage as any).meta;
556
+ if (meta && meta.total > allCollections.length) {
557
+ const totalPages = Math.ceil(meta.total / 100);
558
+ for (let page = 2; page <= totalPages; page++) {
559
+ const nextPage = await sdk.collections.list({ page, limit: 100 });
560
+ allCollections = allCollections.concat(nextPage.data ?? []);
561
+ }
562
+ }
563
+
564
+ if (allCollections.length === 0) {
565
+ return {
566
+ content: [{ type: "text", text: "No collections found in this workspace." }],
567
+ };
568
+ }
569
+
570
+ // Fetch full schema for each collection
571
+ const details = await Promise.all(
572
+ allCollections.map(async (col: any) => {
573
+ try {
574
+ const full = await sdk.collections.getBySlug(col.recordSlug);
575
+ const detail: any = { ...(full.data ?? col) };
576
+
577
+ if (includeRecordCounts) {
578
+ try {
579
+ const records = await sdk.queryRecords(col.recordSlug, { pageSize: 1 } as any);
580
+ detail._recordCount = (records as any).meta?.total ?? "unknown";
581
+ } catch {
582
+ detail._recordCount = "unknown";
583
+ }
584
+ }
585
+
586
+ return detail;
587
+ } catch {
588
+ return { ...col, _fetchError: true };
589
+ }
590
+ })
591
+ );
592
+
593
+ // Format as compact readable text
594
+ const lines: string[] = [];
595
+ for (const col of details) {
596
+ lines.push(`=== Collection: ${col.name} (slug: ${col.recordSlug}) ===`);
597
+ if (col.description) {
598
+ lines.push(`Description: ${col.description}`);
599
+ }
600
+ lines.push(`Schema mode: ${col.schemaDiscoveryMode ?? "strict"}`);
601
+ if (col.tags && col.tags.length > 0) {
602
+ lines.push(`Tags: ${col.tags.join(", ")}`);
603
+ }
604
+
605
+ if (col._fetchError) {
606
+ lines.push("Fields: (unable to fetch full schema)");
607
+ } else if (col.properties && col.properties.length > 0) {
608
+ lines.push("Fields:");
609
+ for (const prop of col.properties) {
610
+ const parts: string[] = [];
611
+
612
+ // Base type
613
+ let typeStr = prop.type;
614
+ if (prop.type === "string" && prop.renderAs) {
615
+ typeStr = prop.renderAs;
616
+ }
617
+ if (prop.type === "array" && prop.items?.type) {
618
+ typeStr = `array of ${prop.items.type}`;
619
+ }
620
+ if (prop.type === "reference" && prop.target) {
621
+ typeStr = `reference -> ${prop.target}`;
622
+ if (prop.relationship) typeStr += ` (${prop.relationship})`;
623
+ }
624
+
625
+ // Enum / select values
626
+ if (prop.enum && prop.enum.length > 0) {
627
+ const vals = prop.enum.map(String);
628
+ if (vals.length <= 8) {
629
+ typeStr = `select: ${vals.join("|")}`;
630
+ } else {
631
+ typeStr = `select: ${vals.slice(0, 6).join("|")}|... (${vals.length} values)`;
632
+ }
633
+ }
634
+
635
+ parts.push(typeStr);
636
+
637
+ // Constraints
638
+ if (prop.required) parts.push("required");
639
+ if (prop.isUnique) parts.push("unique");
640
+ if (prop.immutable) parts.push("immutable");
641
+ if (prop.isSecret) parts.push("secret");
642
+ if (prop.nullable) parts.push("nullable");
643
+ if (prop.default !== undefined) parts.push(`default: ${JSON.stringify(prop.default)}`);
644
+ if (prop.minimum !== undefined) parts.push(`min: ${prop.minimum}`);
645
+ if (prop.maximum !== undefined) parts.push(`max: ${prop.maximum}`);
646
+ if (prop.minLength !== undefined) parts.push(`minLen: ${prop.minLength}`);
647
+ if (prop.maxLength !== undefined) parts.push(`maxLen: ${prop.maxLength}`);
648
+ if (prop.pattern) parts.push(`pattern: ${prop.pattern}`);
649
+ if (prop.expression) parts.push("computed");
650
+ if (prop.autoIncrement) parts.push("auto-increment");
651
+
652
+ lines.push(` - ${prop.name} (${parts.join(", ")})`);
653
+ }
654
+ } else {
655
+ lines.push("Fields: (none)");
656
+ }
657
+
658
+ if (includeRecordCounts && col._recordCount !== undefined) {
659
+ lines.push(`Records: ${col._recordCount}`);
660
+ }
661
+
662
+ lines.push("");
663
+ }
664
+
665
+ lines.push(`Total: ${details.length} collection(s)`);
666
+
667
+ return {
668
+ content: [{ type: "text", text: lines.join("\n") }],
669
+ };
670
+ } catch (error: unknown) {
671
+ return {
672
+ content: [
673
+ {
674
+ type: "text",
675
+ text: formatError(error, "exploring collections"),
676
+ },
677
+ ],
678
+ isError: true,
679
+ };
680
+ }
681
+ }
682
+ );
543
683
  }