@centrali-io/centrali-mcp 5.4.0 → 5.5.0

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.
@@ -3,6 +3,187 @@ import { CentraliSDK } from "@centrali-io/centrali-sdk";
3
3
  import axios, { AxiosInstance } from "axios";
4
4
  import { z } from "zod";
5
5
  import { registerTool, formatError } from "./_register.js";
6
+
7
+ // ── Legacy 5.4.0 query_records translator (Sunset 2026-10-28) ──────────────
8
+ //
9
+ // 5.4.0 MCP clients call query_records with:
10
+ // { recordSlug, filters: { 'data.x[gte]': 1 }, sort: '-createdAt',
11
+ // page: 2, pageSize: 50, expand: 'customer', dateWindow: {...},
12
+ // includeDeleted, includeTotal }
13
+ //
14
+ // Canonical (Phase 1) accepts:
15
+ // { resource, where, sort: [{field, direction}], page: { limit, offset } }
16
+ //
17
+ // We accept BOTH on the wire and rewrite legacy → canonical before calling the
18
+ // SDK, matching the data-service contract promise of long-lived legacy reads.
19
+
20
+ const LEGACY_OP_MAP: Record<string, string> = {
21
+ $eq: "eq", $ne: "ne", $gt: "gt", $gte: "gte", $lt: "lt", $lte: "lte",
22
+ $in: "in", $nin: "nin", $contains: "contains",
23
+ $startsWith: "startsWith", $endsWith: "endsWith", $exists: "exists",
24
+ eq: "eq", ne: "ne", gt: "gt", gte: "gte", lt: "lt", lte: "lte",
25
+ in: "in", nin: "nin", contains: "contains",
26
+ startswith: "startsWith", endswith: "endsWith",
27
+ hasany: "hasAny", hasall: "hasAll",
28
+ };
29
+
30
+ let legacyWarned = false;
31
+ function warnLegacyOnce() {
32
+ if (!legacyWarned) {
33
+ legacyWarned = true;
34
+ console.warn(
35
+ "[centrali-mcp] query_records called with the legacy 5.4.0 schema " +
36
+ "(recordSlug/filters/sort:string/pageSize). It still works but will be " +
37
+ "removed after 2026-10-28. Migrate to: { resource, where, " +
38
+ "sort: [{field,direction}], page: { limit, offset } }.",
39
+ );
40
+ }
41
+ }
42
+
43
+ function parseLegacyFilters(filters: unknown): unknown {
44
+ if (filters == null || typeof filters !== "object" || Array.isArray(filters)) {
45
+ return undefined;
46
+ }
47
+ const conditions: Array<Record<string, unknown>> = [];
48
+ for (const [rawKey, value] of Object.entries(filters as Record<string, unknown>)) {
49
+ const m = rawKey.match(/^(.+?)\[\$?([a-zA-Z]+)\]$/);
50
+ if (m) {
51
+ const [, field, op] = m;
52
+ const canonOp = LEGACY_OP_MAP[op] ?? LEGACY_OP_MAP[op.toLowerCase()] ?? op;
53
+ conditions.push({ [field]: { [canonOp]: value } });
54
+ } else {
55
+ conditions.push({ [rawKey]: { eq: value } });
56
+ }
57
+ }
58
+ if (conditions.length === 0) return undefined;
59
+ if (conditions.length === 1) return conditions[0];
60
+ return { and: conditions };
61
+ }
62
+
63
+ function parseLegacySort(sort: string): Array<{ field: string; direction: "asc" | "desc" }> {
64
+ return sort
65
+ .split(",")
66
+ .map((s) => s.trim())
67
+ .filter(Boolean)
68
+ .map((s) =>
69
+ s.startsWith("-")
70
+ ? { field: s.slice(1), direction: "desc" as const }
71
+ : { field: s, direction: "asc" as const },
72
+ );
73
+ }
74
+
75
+ function dateWindowToWhere(dw: { field: string; from?: string; to?: string }): unknown {
76
+ const conds: Array<Record<string, unknown>> = [];
77
+ if (dw.from !== undefined) conds.push({ [dw.field]: { gte: dw.from } });
78
+ if (dw.to !== undefined) conds.push({ [dw.field]: { lte: dw.to } });
79
+ if (conds.length === 0) return undefined;
80
+ if (conds.length === 1) return conds[0];
81
+ return { and: conds };
82
+ }
83
+
84
+ type QueryRecordsArgs = {
85
+ resource?: string;
86
+ recordSlug?: string;
87
+ where?: unknown;
88
+ text?: unknown;
89
+ sort?: unknown;
90
+ page?: unknown;
91
+ pageSize?: number;
92
+ select?: unknown;
93
+ include?: unknown;
94
+ filters?: Record<string, unknown>;
95
+ expand?: string;
96
+ dateWindow?: { field: string; from?: string; to?: string };
97
+ includeDeleted?: boolean;
98
+ includeTotal?: boolean;
99
+ };
100
+
101
+ function translateQueryRecordsArgs(args: QueryRecordsArgs): {
102
+ resource: string;
103
+ definition: Record<string, unknown>;
104
+ } {
105
+ const isLegacy =
106
+ args.recordSlug !== undefined ||
107
+ args.filters !== undefined ||
108
+ typeof args.sort === "string" ||
109
+ typeof args.page === "number" ||
110
+ args.pageSize !== undefined ||
111
+ args.expand !== undefined ||
112
+ args.dateWindow !== undefined ||
113
+ args.includeDeleted !== undefined ||
114
+ args.includeTotal !== undefined;
115
+
116
+ if (isLegacy) warnLegacyOnce();
117
+
118
+ const resource = args.resource ?? args.recordSlug;
119
+ if (!resource) {
120
+ throw new Error(
121
+ "query_records requires either `resource` (canonical) or `recordSlug` (legacy)",
122
+ );
123
+ }
124
+
125
+ // POST /records/query Phase 1 has no soft-delete plumbing — silently dropping
126
+ // `includeDeleted: true` would be a privacy regression vs 5.4.0 GET behavior
127
+ // (caller asks for tombstones, gets only live rows). Surface a clear error.
128
+ if (args.includeDeleted === true) {
129
+ throw new Error(
130
+ "query_records `includeDeleted: true` is not supported in Phase 1. " +
131
+ "Use the GET /records/slug/:rs endpoint with ?includeDeleted=true until Phase 2 lands.",
132
+ );
133
+ }
134
+
135
+ const definition: Record<string, unknown> = { resource };
136
+
137
+ const whereParts: unknown[] = [];
138
+ if (args.where !== undefined) whereParts.push(args.where);
139
+ if (args.filters !== undefined) {
140
+ const fw = parseLegacyFilters(args.filters);
141
+ if (fw !== undefined) whereParts.push(fw);
142
+ }
143
+ if (args.dateWindow !== undefined) {
144
+ const dw = dateWindowToWhere(args.dateWindow);
145
+ if (dw !== undefined) whereParts.push(dw);
146
+ }
147
+ if (whereParts.length === 1) definition.where = whereParts[0];
148
+ else if (whereParts.length > 1) definition.where = { and: whereParts };
149
+
150
+ if (args.text !== undefined) definition.text = args.text;
151
+
152
+ if (args.sort !== undefined) {
153
+ definition.sort = typeof args.sort === "string" ? parseLegacySort(args.sort) : args.sort;
154
+ }
155
+
156
+ if (args.page !== undefined || args.pageSize !== undefined) {
157
+ if (typeof args.page === "object" && args.page !== null) {
158
+ definition.page = args.page;
159
+ } else {
160
+ const limit = args.pageSize ?? 50;
161
+ const pageNum = typeof args.page === "number" ? args.page : 1;
162
+ const offset = (pageNum - 1) * limit;
163
+ definition.page = offset > 0 ? { limit, offset } : { limit };
164
+ }
165
+ }
166
+
167
+ if (args.select !== undefined) definition.select = args.select;
168
+
169
+ if (args.include !== undefined) {
170
+ definition.include = args.include;
171
+ } else if (args.expand !== undefined) {
172
+ const relations = args.expand
173
+ .split(",")
174
+ .map((s) => ({ relation: s.trim() }))
175
+ .filter((r) => r.relation.length > 0);
176
+ if (relations.length > 0) definition.include = relations;
177
+ }
178
+
179
+ // `includeTotal` is dropped — `meta.total` is always returned for offset
180
+ // pagination on POST /records/query. `includeDeleted` is rejected above.
181
+
182
+ return { resource, definition };
183
+ }
184
+
185
+ // Exposed for tests
186
+ export const _internal = { translateQueryRecordsArgs, parseLegacyFilters, parseLegacySort };
6
187
  /**
7
188
  * Ensures the SDK has a valid token.
8
189
  */
@@ -10,7 +191,7 @@ async function ensureToken(sdk: CentraliSDK): Promise<string | null> {
10
191
  let token = sdk.getToken();
11
192
  if (token) return token;
12
193
  try {
13
- await sdk.queryRecords("__noop__", { limit: 1 });
194
+ await sdk.records.query("__noop__", { resource: "__noop__", page: { limit: 1 } });
14
195
  } catch { /* token refresh side effect */ }
15
196
  return sdk.getToken();
16
197
  }
@@ -44,7 +225,7 @@ function createRecordsClient(sdk: CentraliSDK, centraliUrl: string, workspaceId:
44
225
  if (isAuthError && !originalRequest._hasRetried) {
45
226
  originalRequest._hasRetried = true;
46
227
  try {
47
- await sdk.queryRecords("__noop__", { limit: 1 });
228
+ await sdk.records.query("__noop__", { resource: "__noop__", page: { limit: 1 } });
48
229
  } catch { /* token refresh side effect */ }
49
230
 
50
231
  const token = sdk.getToken();
@@ -61,73 +242,113 @@ function createRecordsClient(sdk: CentraliSDK, centraliUrl: string, workspaceId:
61
242
  }
62
243
 
63
244
  export function registerRecordTools(server: McpServer, sdk: CentraliSDK, centraliUrl: string, workspaceId: string) {
64
- registerTool<any>(server,
245
+ registerTool<any>(server,
65
246
  "query_records",
66
- "Query records from a collection with optional filters, sorting, pagination, and date range filtering. Filters use 'data.' prefix for custom fields and bracket notation for operators (e.g., 'data.status': 'active', 'data.price[lte]': 100). Use dateWindow for date range queries.",
247
+ `Query records from a collection using the canonical Centrali query language (POST /records/query).
248
+
249
+ The body is a QueryDefinition. Field paths use dotted strings ('data.status', 'data.customer.email'); the field operators (no '$' prefix) are: eq, ne, gt, gte, lt, lte, in, nin, contains, startsWith, endsWith, hasAny, hasAll, exists. Boolean trees use 'and', 'or', 'not'.
250
+
251
+ Example body:
252
+ {
253
+ "where": {
254
+ "and": [
255
+ { "data.status": { "eq": "open" } },
256
+ { "data.amount": { "gte": 100 } }
257
+ ]
258
+ },
259
+ "sort": [{ "field": "createdAt", "direction": "desc" }],
260
+ "page": { "limit": 50 },
261
+ "select": { "fields": ["id", "data.status", "data.amount"] }
262
+ }
263
+
264
+ Returns the canonical { data, meta } envelope. 'select', 'text', and 'include' are all accepted by the engine.`,
67
265
  {
68
- recordSlug: z
266
+ // Canonical fields (Phase 1)
267
+ resource: z
69
268
  .string()
70
- .describe("The collection's record slug (e.g., 'orders')"),
71
- filters: z
72
- .record(z.string(), z.any())
269
+ .optional()
270
+ .describe("Collection slug to query (e.g. 'orders'). Required unless legacy `recordSlug` is provided."),
271
+ where: z
272
+ .any()
73
273
  .optional()
74
274
  .describe(
75
- "Filter object with keys like 'data.fieldName' or 'data.fieldName[operator]'. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, startswith, endswith, hasAny, hasAll"
275
+ "WhereExpression either a FieldConditionMap ({ 'data.status': { eq: 'open' } }) or a boolean tree ({ and: [...] } | { or: [...] } | { not: ... }). Each FieldCondition must use exactly one operator."
76
276
  ),
277
+ text: z
278
+ .object({
279
+ query: z.string().describe("Search string"),
280
+ fields: z.array(z.string()).optional().describe("Restrict to these fields"),
281
+ typoTolerance: z.boolean().optional(),
282
+ })
283
+ .optional()
284
+ .describe("Full-text search clause. Routes to the search executor when set."),
77
285
  sort: z
286
+ .any()
287
+ .optional()
288
+ .describe(
289
+ "Canonical: array of { field, direction: 'asc'|'desc' }. Legacy: string '-createdAt' (deprecated, translated automatically)."
290
+ ),
291
+ page: z
292
+ .any()
293
+ .optional()
294
+ .describe(
295
+ "Canonical: { limit, offset|cursor }. Legacy: page number (deprecated; combine with `pageSize`)."
296
+ ),
297
+ select: z
298
+ .object({
299
+ fields: z.array(z.string()).describe("Field paths to return"),
300
+ })
301
+ .optional()
302
+ .describe("Field projection. Example: { fields: ['id', 'data.status'] }."),
303
+ include: z
304
+ .array(z.object({ relation: z.string() }))
305
+ .optional()
306
+ .describe("Relation expansion. Each entry names a relation declared on the collection."),
307
+
308
+ // Legacy 5.4.0 fields — accepted, translated to canonical, removed after 2026-10-28
309
+ recordSlug: z
78
310
  .string()
79
311
  .optional()
312
+ .describe("[Deprecated 2026-10-28] Legacy alias for `resource`."),
313
+ filters: z
314
+ .record(z.string(), z.any())
315
+ .optional()
80
316
  .describe(
81
- "Sort field with optional '-' prefix for descending (e.g., '-createdAt')"
317
+ "[Deprecated 2026-10-28] Legacy filter map (e.g. { 'data.status': 'open', 'data.price[lte]': 100 }). Translated to `where`."
82
318
  ),
83
- page: z.number().optional().describe("Page number (1-indexed, default: 1)"),
84
319
  pageSize: z
85
320
  .number()
86
321
  .optional()
87
- .describe("Records per page (default: 50, max: 500)"),
322
+ .describe("[Deprecated 2026-10-28] Legacy page size. Translated to `page.limit`."),
88
323
  expand: z
89
324
  .string()
90
325
  .optional()
91
326
  .describe(
92
- "Comma-separated reference fields to expand (e.g., 'customer,product')"
327
+ "[Deprecated 2026-10-28] Comma-separated relation names (e.g. 'customer,product'). Translated to `include`."
93
328
  ),
94
329
  dateWindow: z
95
330
  .object({
96
- field: z.string().describe("Date field to filter on (e.g., 'createdAt', 'updatedAt')"),
97
- from: z.string().optional().describe("ISO 8601 lower bound (inclusive)"),
98
- to: z.string().optional().describe("ISO 8601 upper bound (inclusive)"),
331
+ field: z.string(),
332
+ from: z.string().optional(),
333
+ to: z.string().optional(),
99
334
  })
100
335
  .optional()
101
- .describe(
102
- "Date range filter. Restricts results to records where the specified date field falls within the given range."
103
- ),
336
+ .describe("[Deprecated 2026-10-28] Date range filter. Translated to `where` with gte/lte."),
104
337
  includeDeleted: z
105
338
  .boolean()
106
339
  .optional()
107
- .describe("Include soft-deleted records (default: false)"),
340
+ .describe("[Deprecated 2026-10-28] Ignored — POST /records/query does not surface deleted rows in Phase 1."),
108
341
  includeTotal: z
109
342
  .boolean()
110
343
  .optional()
111
- .describe("Include total record count in response metadata (default: false)"),
344
+ .describe("[Deprecated 2026-10-28] Ignored — `meta.total` is always returned for offset pagination."),
112
345
  },
113
- async ({ recordSlug, filters, sort, page, pageSize, expand, dateWindow, includeDeleted, includeTotal }) => {
346
+ async (args) => {
347
+ let resource = "<unknown>";
114
348
  try {
115
- const params: Record<string, any> = {
116
- ...filters,
117
- sort,
118
- page,
119
- pageSize,
120
- expand,
121
- dateWindow,
122
- includeDeleted,
123
- includeTotal,
124
- };
125
- // Remove undefined values
126
- Object.keys(params).forEach(
127
- (key) => params[key] === undefined && delete params[key]
128
- );
129
-
130
- const result = await sdk.queryRecords(recordSlug, params);
349
+ const translated = translateQueryRecordsArgs(args as QueryRecordsArgs);
350
+ resource = translated.resource;
351
+ const result = await sdk.records.query(resource, translated.definition as any);
131
352
  return {
132
353
  content: [
133
354
  { type: "text", text: JSON.stringify(result, null, 2) },
@@ -138,7 +359,7 @@ export function registerRecordTools(server: McpServer, sdk: CentraliSDK, central
138
359
  content: [
139
360
  {
140
361
  type: "text",
141
- text: formatError(error, `querying records from '${recordSlug}'`),
362
+ text: formatError(error, `querying records from '${resource}'`),
142
363
  },
143
364
  ],
144
365
  isError: true,
@@ -147,7 +368,7 @@ export function registerRecordTools(server: McpServer, sdk: CentraliSDK, central
147
368
  }
148
369
  );
149
370
 
150
- registerTool<any>(server,
371
+ registerTool<any>(server,
151
372
  "get_record",
152
373
  "Get a single record by its ID from a collection. Optionally expand reference fields.",
153
374
  {
@@ -181,7 +402,7 @@ export function registerRecordTools(server: McpServer, sdk: CentraliSDK, central
181
402
  }
182
403
  );
183
404
 
184
- registerTool<any>(server,
405
+ registerTool<any>(server,
185
406
  "create_record",
186
407
  "Create a new record in a collection. Pass the record data as a JSON object with field names matching the collection's properties.",
187
408
  {
@@ -214,7 +435,7 @@ export function registerRecordTools(server: McpServer, sdk: CentraliSDK, central
214
435
  }
215
436
  );
216
437
 
217
- registerTool<any>(server,
438
+ registerTool<any>(server,
218
439
  "update_record",
219
440
  "Update an existing record by ID. Only include the fields you want to change.",
220
441
  {
@@ -246,7 +467,7 @@ export function registerRecordTools(server: McpServer, sdk: CentraliSDK, central
246
467
  }
247
468
  );
248
469
 
249
- registerTool<any>(server,
470
+ registerTool<any>(server,
250
471
  "delete_record",
251
472
  "Delete a record by ID. Performs a soft delete by default (can be restored). Set hard=true for permanent deletion.",
252
473
  {
@@ -286,7 +507,7 @@ export function registerRecordTools(server: McpServer, sdk: CentraliSDK, central
286
507
  }
287
508
  );
288
509
 
289
- registerTool<any>(server,
510
+ registerTool<any>(server,
290
511
  "upsert_record",
291
512
  "Create or update a record atomically. Matches on the provided fields to find an existing record — updates it if found, creates it if not. Returns the record and whether it was 'created' or 'updated'.",
292
513
  {
@@ -324,7 +545,7 @@ export function registerRecordTools(server: McpServer, sdk: CentraliSDK, central
324
545
  }
325
546
  );
326
547
 
327
- registerTool<any>(server,
548
+ registerTool<any>(server,
328
549
  "get_records_by_ids",
329
550
  "Fetch multiple records by their IDs in a single request. Returns an array of records.",
330
551
  {
@@ -348,7 +569,7 @@ export function registerRecordTools(server: McpServer, sdk: CentraliSDK, central
348
569
  }
349
570
  );
350
571
 
351
- registerTool<any>(server,
572
+ registerTool<any>(server,
352
573
  "restore_record",
353
574
  "Restore a soft-deleted record by ID. Only works on records deleted without hard=true.",
354
575
  {
@@ -376,7 +597,7 @@ export function registerRecordTools(server: McpServer, sdk: CentraliSDK, central
376
597
 
377
598
  // ── Bulk Operations (1 aggregate event per operation) ───────────────
378
599
 
379
- registerTool<any>(server,
600
+ registerTool<any>(server,
380
601
  "bulk_create_records",
381
602
  `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
603
  {
@@ -401,7 +622,7 @@ export function registerRecordTools(server: McpServer, sdk: CentraliSDK, central
401
622
  }
402
623
  );
403
624
 
404
- registerTool<any>(server,
625
+ registerTool<any>(server,
405
626
  "bulk_update_records",
406
627
  `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
628
  {
@@ -427,7 +648,7 @@ export function registerRecordTools(server: McpServer, sdk: CentraliSDK, central
427
648
  }
428
649
  );
429
650
 
430
- registerTool<any>(server,
651
+ registerTool<any>(server,
431
652
  "bulk_delete_records",
432
653
  `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
654
  {
@@ -464,7 +685,7 @@ export function registerRecordTools(server: McpServer, sdk: CentraliSDK, central
464
685
 
465
686
  // ── Batch Operations (1 event per record) ──────────────────────────
466
687
 
467
- registerTool<any>(server,
688
+ registerTool<any>(server,
468
689
  "batch_create_records",
469
690
  `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
691
  {
@@ -495,7 +716,7 @@ export function registerRecordTools(server: McpServer, sdk: CentraliSDK, central
495
716
  }
496
717
  );
497
718
 
498
- registerTool<any>(server,
719
+ registerTool<any>(server,
499
720
  "batch_update_records",
500
721
  `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
722
  {
@@ -527,7 +748,7 @@ export function registerRecordTools(server: McpServer, sdk: CentraliSDK, central
527
748
  }
528
749
  );
529
750
 
530
- registerTool<any>(server,
751
+ registerTool<any>(server,
531
752
  "batch_delete_records",
532
753
  `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
754
  {