@centrali-io/centrali-mcp 5.3.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.
@@ -2,23 +2,42 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { CentraliSDK } from "@centrali-io/centrali-sdk";
3
3
  import { z } from "zod";
4
4
  import { registerTool, formatError } from "./_register.js";
5
+
6
+ // Tool names below stay as `*_smart_query` to keep existing AI assistant
7
+ // configurations working — renaming would break public clients. Internally
8
+ // they route through `sdk.savedQueries.*` (the canonical namespace) and
9
+ // accept canonical query bodies.
10
+
11
+ const CANONICAL_QUERY_DEFINITION_HINT = `Canonical QueryDefinition body (saved queries store the inner shape — 'resource' is filled in from the collection slug arg).
12
+
13
+ Field paths use dotted strings ('data.status'); operators (no '$' prefix): eq, ne, gt, gte, lt, lte, in, nin, contains, startsWith, endsWith, hasAny, hasAll, exists. Boolean trees use 'and', 'or', 'not'.
14
+
15
+ Variables are referenced as '{{varName}}' inside operator values; the engine infers the variable list from the placeholders in the body. Callers pass concrete values via the 'variables' arg on execute_smart_query / test_smart_query. Example:
16
+ {
17
+ "where": { "data.status": { "eq": "{{statusFilter}}" } },
18
+ "sort": [{ "field": "createdAt", "direction": "desc" }],
19
+ "page": { "limit": 100 }
20
+ }
21
+
22
+ Do NOT use legacy '\$'-prefixed operators ('\$eq', '\$gte', …). The data service still translates them server-side during the deprecation window, but new saved queries must author canonical operators.`;
23
+
5
24
  export function registerSmartQueryTools(server: McpServer, sdk: CentraliSDK) {
6
- registerTool<any>(server,
25
+ registerTool<any>(server,
7
26
  "list_smart_queries",
8
- "List smart queries. Smart queries are reusable, parameterized queries defined in the Centrali console. Optionally filter by collection record slug.",
27
+ "List saved (a.k.a. smart) queries. Saved queries are reusable, parameterized queries defined in the Centrali console. Optionally filter by collection record slug.",
9
28
  {
10
29
  recordSlug: z
11
30
  .string()
12
31
  .optional()
13
32
  .describe(
14
- "Filter by collection record slug. If omitted, lists all smart queries in the workspace"
33
+ "Filter by collection record slug. If omitted, lists all saved queries in the workspace"
15
34
  ),
16
35
  },
17
36
  async ({ recordSlug }) => {
18
37
  try {
19
38
  const result = recordSlug
20
- ? await sdk.smartQueries.list(recordSlug)
21
- : await sdk.smartQueries.listAll();
39
+ ? await sdk.savedQueries.list(recordSlug)
40
+ : await sdk.savedQueries.listAll();
22
41
  return {
23
42
  content: [
24
43
  { type: "text", text: JSON.stringify(result.data, null, 2) },
@@ -29,7 +48,7 @@ export function registerSmartQueryTools(server: McpServer, sdk: CentraliSDK) {
29
48
  content: [
30
49
  {
31
50
  type: "text",
32
- text: formatError(error, "listing smart queries"),
51
+ text: formatError(error, "listing saved queries"),
33
52
  },
34
53
  ],
35
54
  isError: true,
@@ -38,25 +57,25 @@ export function registerSmartQueryTools(server: McpServer, sdk: CentraliSDK) {
38
57
  }
39
58
  );
40
59
 
41
- registerTool<any>(server,
60
+ registerTool<any>(server,
42
61
  "execute_smart_query",
43
- "Execute a smart query by ID and return the results. Smart queries can have parameterized variables using {{variableName}} syntax.",
62
+ "Execute a saved query by ID and return the canonical { data, meta } envelope. Saved queries can declare variables referenced as '{{varName}}' inside operator values.",
44
63
  {
45
64
  recordSlug: z
46
65
  .string()
47
66
  .describe("The collection's record slug the query belongs to"),
48
- queryId: z.string().describe("The smart query ID (UUID) to execute"),
67
+ queryId: z.string().describe("The saved query ID (UUID) to execute"),
49
68
  variables: z
50
- .record(z.string(), z.string())
69
+ .record(z.string(), z.any())
51
70
  .optional()
52
71
  .describe(
53
- "Variables to substitute in the query (key-value pairs matching {{variableName}} placeholders)"
72
+ "Variables to substitute (key-value pairs matching '{{varName}}' placeholders). Values must match the variable's declared type."
54
73
  ),
55
74
  },
56
75
  async ({ recordSlug, queryId, variables }) => {
57
76
  try {
58
77
  const options = variables ? { variables } : undefined;
59
- const result = await sdk.smartQueries.execute(
78
+ const result = await sdk.savedQueries.execute(
60
79
  recordSlug,
61
80
  queryId,
62
81
  options
@@ -71,7 +90,7 @@ export function registerSmartQueryTools(server: McpServer, sdk: CentraliSDK) {
71
90
  content: [
72
91
  {
73
92
  type: "text",
74
- text: formatError(error, `executing smart query '${queryId}'`),
93
+ text: formatError(error, `executing saved query '${queryId}'`),
75
94
  },
76
95
  ],
77
96
  isError: true,
@@ -80,16 +99,16 @@ export function registerSmartQueryTools(server: McpServer, sdk: CentraliSDK) {
80
99
  }
81
100
  );
82
101
 
83
- registerTool<any>(server,
102
+ registerTool<any>(server,
84
103
  "get_smart_query",
85
- "Get a smart query by ID. Returns the full query definition including filters, sort, and variable declarations.",
104
+ "Get a saved query by ID. Returns the full query definition including canonical 'where', 'sort', 'page', 'select', and variable declarations.",
86
105
  {
87
106
  recordSlug: z.string().describe("The collection's record slug the query belongs to"),
88
- queryId: z.string().describe("The smart query ID (UUID)"),
107
+ queryId: z.string().describe("The saved query ID (UUID)"),
89
108
  },
90
109
  async ({ recordSlug, queryId }) => {
91
110
  try {
92
- const result = await sdk.smartQueries.get(recordSlug, queryId);
111
+ const result = await sdk.savedQueries.get(recordSlug, queryId);
93
112
  return {
94
113
  content: [
95
114
  { type: "text", text: JSON.stringify(result.data, null, 2) },
@@ -100,7 +119,7 @@ export function registerSmartQueryTools(server: McpServer, sdk: CentraliSDK) {
100
119
  content: [
101
120
  {
102
121
  type: "text",
103
- text: formatError(error, `getting smart query '${queryId}'`),
122
+ text: formatError(error, `getting saved query '${queryId}'`),
104
123
  },
105
124
  ],
106
125
  isError: true,
@@ -109,23 +128,27 @@ export function registerSmartQueryTools(server: McpServer, sdk: CentraliSDK) {
109
128
  }
110
129
  );
111
130
 
112
- registerTool<any>(server,
131
+ registerTool<any>(server,
113
132
  "create_smart_query",
114
- "Create a new smart query for a collection. Smart queries are reusable, parameterized queries with filter, sort, and variable support.",
133
+ `Create a new saved query for a collection.
134
+
135
+ ${CANONICAL_QUERY_DEFINITION_HINT}`,
115
136
  {
116
137
  recordSlug: z.string().describe("The collection's record slug to create the query for"),
117
- name: z.string().describe("Display name for the smart query"),
138
+ name: z.string().describe("Display name for the saved query"),
118
139
  description: z.string().optional().describe("Optional description"),
119
140
  queryDefinition: z
120
141
  .record(z.string(), z.any())
121
- .describe("The query definition object with where, sort, limit, select, join, etc."),
142
+ .describe(
143
+ "Canonical inner QueryDefinition (without 'resource'). Use 'where', 'text', 'sort', 'page', 'select'. Variables are inferred from '{{varName}}' placeholders inside operator values."
144
+ ),
122
145
  },
123
146
  async ({ recordSlug, name, description, queryDefinition }) => {
124
147
  try {
125
148
  const input: Record<string, any> = { name, queryDefinition };
126
149
  if (description !== undefined) input.description = description;
127
150
 
128
- const result = await sdk.smartQueries.create(recordSlug, input as any);
151
+ const result = await sdk.savedQueries.create(recordSlug, input as any);
129
152
  return {
130
153
  content: [
131
154
  { type: "text", text: JSON.stringify(result.data, null, 2) },
@@ -136,7 +159,7 @@ export function registerSmartQueryTools(server: McpServer, sdk: CentraliSDK) {
136
159
  content: [
137
160
  {
138
161
  type: "text",
139
- text: formatError(error, `creating smart query '${name}' for '${recordSlug}'`),
162
+ text: formatError(error, `creating saved query '${name}' for '${recordSlug}'`),
140
163
  },
141
164
  ],
142
165
  isError: true,
@@ -145,18 +168,20 @@ export function registerSmartQueryTools(server: McpServer, sdk: CentraliSDK) {
145
168
  }
146
169
  );
147
170
 
148
- registerTool<any>(server,
171
+ registerTool<any>(server,
149
172
  "update_smart_query",
150
- "Update an existing smart query. Only include the fields you want to change.",
173
+ `Update an existing saved query. Only include the fields you want to change.
174
+
175
+ ${CANONICAL_QUERY_DEFINITION_HINT}`,
151
176
  {
152
177
  recordSlug: z.string().describe("The collection's record slug the query belongs to"),
153
- queryId: z.string().describe("The smart query ID (UUID) to update"),
178
+ queryId: z.string().describe("The saved query ID (UUID) to update"),
154
179
  name: z.string().optional().describe("Updated display name"),
155
180
  description: z.string().optional().describe("Updated description"),
156
181
  queryDefinition: z
157
182
  .record(z.string(), z.any())
158
183
  .optional()
159
- .describe("Updated query definition object"),
184
+ .describe("Updated canonical inner QueryDefinition (without 'resource')."),
160
185
  },
161
186
  async ({ recordSlug, queryId, name, description, queryDefinition }) => {
162
187
  try {
@@ -165,7 +190,7 @@ export function registerSmartQueryTools(server: McpServer, sdk: CentraliSDK) {
165
190
  if (description !== undefined) input.description = description;
166
191
  if (queryDefinition !== undefined) input.queryDefinition = queryDefinition;
167
192
 
168
- const result = await sdk.smartQueries.update(recordSlug, queryId, input as any);
193
+ const result = await sdk.savedQueries.update(recordSlug, queryId, input as any);
169
194
  return {
170
195
  content: [
171
196
  { type: "text", text: JSON.stringify(result.data, null, 2) },
@@ -176,7 +201,7 @@ export function registerSmartQueryTools(server: McpServer, sdk: CentraliSDK) {
176
201
  content: [
177
202
  {
178
203
  type: "text",
179
- text: formatError(error, `updating smart query '${queryId}'`),
204
+ text: formatError(error, `updating saved query '${queryId}'`),
180
205
  },
181
206
  ],
182
207
  isError: true,
@@ -185,21 +210,21 @@ export function registerSmartQueryTools(server: McpServer, sdk: CentraliSDK) {
185
210
  }
186
211
  );
187
212
 
188
- registerTool<any>(server,
213
+ registerTool<any>(server,
189
214
  "delete_smart_query",
190
- "Delete a smart query by ID.",
215
+ "Delete a saved query by ID.",
191
216
  {
192
217
  recordSlug: z.string().describe("The collection's record slug the query belongs to"),
193
- queryId: z.string().describe("The smart query ID (UUID) to delete"),
218
+ queryId: z.string().describe("The saved query ID (UUID) to delete"),
194
219
  },
195
220
  async ({ recordSlug, queryId }) => {
196
221
  try {
197
- await sdk.smartQueries.delete(recordSlug, queryId);
222
+ await sdk.savedQueries.delete(recordSlug, queryId);
198
223
  return {
199
224
  content: [
200
225
  {
201
226
  type: "text",
202
- text: `Smart query '${queryId}' deleted from '${recordSlug}'.`,
227
+ text: `Saved query '${queryId}' deleted from '${recordSlug}'.`,
203
228
  },
204
229
  ],
205
230
  };
@@ -208,7 +233,7 @@ export function registerSmartQueryTools(server: McpServer, sdk: CentraliSDK) {
208
233
  content: [
209
234
  {
210
235
  type: "text",
211
- text: formatError(error, `deleting smart query '${queryId}'`),
236
+ text: formatError(error, `deleting saved query '${queryId}'`),
212
237
  },
213
238
  ],
214
239
  isError: true,
@@ -217,25 +242,27 @@ export function registerSmartQueryTools(server: McpServer, sdk: CentraliSDK) {
217
242
  }
218
243
  );
219
244
 
220
- registerTool<any>(server,
245
+ registerTool<any>(server,
221
246
  "test_smart_query",
222
- "Test execute a query definition without saving it. Useful for validating query syntax and previewing results before creating a smart query.",
247
+ `Test execute a query definition without saving it. Useful for validating syntax and previewing results before creating a saved query.
248
+
249
+ ${CANONICAL_QUERY_DEFINITION_HINT}`,
223
250
  {
224
251
  recordSlug: z.string().describe("The collection's record slug to test against"),
225
252
  queryDefinition: z
226
253
  .record(z.string(), z.any())
227
- .describe("The query definition to test (where, sort, limit, select, etc.)"),
254
+ .describe("Canonical inner QueryDefinition to test (without 'resource')."),
228
255
  variables: z
229
- .record(z.string(), z.string())
256
+ .record(z.string(), z.any())
230
257
  .optional()
231
- .describe("Optional variables to substitute in the query"),
258
+ .describe("Optional variables to substitute."),
232
259
  },
233
260
  async ({ recordSlug, queryDefinition, variables }) => {
234
261
  try {
235
262
  const input: Record<string, any> = { queryDefinition };
236
263
  if (variables !== undefined) input.variables = variables;
237
264
 
238
- const result = await sdk.smartQueries.test(recordSlug, input as any);
265
+ const result = await sdk.savedQueries.test(recordSlug, input as any);
239
266
  return {
240
267
  content: [
241
268
  { type: "text", text: JSON.stringify(result.data, null, 2) },
@@ -246,7 +273,7 @@ export function registerSmartQueryTools(server: McpServer, sdk: CentraliSDK) {
246
273
  content: [
247
274
  {
248
275
  type: "text",
249
- text: formatError(error, `test-executing smart query for '${recordSlug}'`),
276
+ text: formatError(error, `test-executing saved query for '${recordSlug}'`),
250
277
  },
251
278
  ],
252
279
  isError: true,
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Legacy 5.4.0 → canonical translator tests for `query_records`.
3
+ *
4
+ * Runs against the built `dist/` so we exercise the same module the published
5
+ * package ships. Uses Node's built-in `node:test` runner — no jest/ts-node
6
+ * needed. Wire via `npm test` (`tsc && node --test tests/`).
7
+ */
8
+ const test = require("node:test");
9
+ const assert = require("node:assert/strict");
10
+ const { _internal } = require("../dist/tools/records.js");
11
+
12
+ const { translateQueryRecordsArgs, parseLegacyFilters, parseLegacySort } = _internal;
13
+
14
+ test("canonical args pass through unchanged", () => {
15
+ const out = translateQueryRecordsArgs({
16
+ resource: "orders",
17
+ where: { "data.status": { eq: "open" } },
18
+ page: { limit: 50 },
19
+ });
20
+ assert.deepEqual(out, {
21
+ resource: "orders",
22
+ definition: {
23
+ resource: "orders",
24
+ where: { "data.status": { eq: "open" } },
25
+ page: { limit: 50 },
26
+ },
27
+ });
28
+ });
29
+
30
+ test("full 5.4.0 legacy shape translates", () => {
31
+ const out = translateQueryRecordsArgs({
32
+ recordSlug: "orders",
33
+ filters: { "data.status": "open", "data.price[gte]": 100 },
34
+ sort: "-createdAt",
35
+ pageSize: 50,
36
+ page: 2,
37
+ });
38
+ assert.deepEqual(out, {
39
+ resource: "orders",
40
+ definition: {
41
+ resource: "orders",
42
+ where: {
43
+ and: [
44
+ { "data.status": { eq: "open" } },
45
+ { "data.price": { gte: 100 } },
46
+ ],
47
+ },
48
+ sort: [{ field: "createdAt", direction: "desc" }],
49
+ page: { limit: 50, offset: 50 },
50
+ },
51
+ });
52
+ });
53
+
54
+ test("filters: $-prefixed operator alias", () => {
55
+ const out = translateQueryRecordsArgs({
56
+ recordSlug: "orders",
57
+ filters: { "data.amount[$gte]": 100 },
58
+ });
59
+ assert.deepEqual(out.definition.where, { "data.amount": { gte: 100 } });
60
+ });
61
+
62
+ test("filters: single condition omits 'and' wrapping", () => {
63
+ const out = translateQueryRecordsArgs({
64
+ recordSlug: "orders",
65
+ filters: { "data.status": "open" },
66
+ });
67
+ assert.deepEqual(out.definition.where, { "data.status": { eq: "open" } });
68
+ });
69
+
70
+ test("filters + canonical where merge under 'and'", () => {
71
+ const out = translateQueryRecordsArgs({
72
+ resource: "orders",
73
+ where: { "data.region": { eq: "us-east" } },
74
+ filters: { "data.status": "open" },
75
+ });
76
+ assert.deepEqual(out.definition.where, {
77
+ and: [
78
+ { "data.region": { eq: "us-east" } },
79
+ { "data.status": { eq: "open" } },
80
+ ],
81
+ });
82
+ });
83
+
84
+ test("dateWindow: from + to → and(gte, lte)", () => {
85
+ const out = translateQueryRecordsArgs({
86
+ recordSlug: "orders",
87
+ dateWindow: { field: "createdAt", from: "2026-01-01", to: "2026-04-01" },
88
+ });
89
+ assert.deepEqual(out.definition.where, {
90
+ and: [
91
+ { createdAt: { gte: "2026-01-01" } },
92
+ { createdAt: { lte: "2026-04-01" } },
93
+ ],
94
+ });
95
+ });
96
+
97
+ test("dateWindow: from-only → single gte (no 'and')", () => {
98
+ const out = translateQueryRecordsArgs({
99
+ recordSlug: "orders",
100
+ dateWindow: { field: "createdAt", from: "2026-01-01" },
101
+ });
102
+ assert.deepEqual(out.definition.where, { createdAt: { gte: "2026-01-01" } });
103
+ });
104
+
105
+ test("dateWindow: to-only → single lte (no 'and')", () => {
106
+ const out = translateQueryRecordsArgs({
107
+ recordSlug: "orders",
108
+ dateWindow: { field: "createdAt", to: "2026-04-01" },
109
+ });
110
+ assert.deepEqual(out.definition.where, { createdAt: { lte: "2026-04-01" } });
111
+ });
112
+
113
+ test("expand: comma-list → include array; empty → omit", () => {
114
+ const ok = translateQueryRecordsArgs({
115
+ recordSlug: "orders",
116
+ expand: "customer,product",
117
+ });
118
+ assert.deepEqual(ok.definition.include, [
119
+ { relation: "customer" },
120
+ { relation: "product" },
121
+ ]);
122
+
123
+ const empty = translateQueryRecordsArgs({ recordSlug: "orders", expand: "" });
124
+ assert.equal(empty.definition.include, undefined);
125
+
126
+ const allEmpty = translateQueryRecordsArgs({ recordSlug: "orders", expand: ",,," });
127
+ assert.equal(allEmpty.definition.include, undefined);
128
+ });
129
+
130
+ test("sort: multi-sort comma-separated mixes asc/desc", () => {
131
+ assert.deepEqual(parseLegacySort("name,-createdAt"), [
132
+ { field: "name", direction: "asc" },
133
+ { field: "createdAt", direction: "desc" },
134
+ ]);
135
+ });
136
+
137
+ test("page without pageSize defaults to limit 50", () => {
138
+ const out = translateQueryRecordsArgs({ recordSlug: "orders", page: 3 });
139
+ assert.deepEqual(out.definition.page, { limit: 50, offset: 100 });
140
+ });
141
+
142
+ test("pageSize without page → limit only", () => {
143
+ const out = translateQueryRecordsArgs({ recordSlug: "orders", pageSize: 25 });
144
+ assert.deepEqual(out.definition.page, { limit: 25 });
145
+ });
146
+
147
+ test("includeTotal silently dropped (meta.total always returned)", () => {
148
+ const out = translateQueryRecordsArgs({ recordSlug: "orders", includeTotal: true });
149
+ assert.deepEqual(out.definition, { resource: "orders" });
150
+ });
151
+
152
+ test("includeDeleted: true throws clear error (no silent privacy regression)", () => {
153
+ assert.throws(
154
+ () => translateQueryRecordsArgs({ recordSlug: "orders", includeDeleted: true }),
155
+ /includeDeleted.*not supported in Phase 1/,
156
+ );
157
+ });
158
+
159
+ test("missing both resource and recordSlug throws", () => {
160
+ assert.throws(
161
+ () => translateQueryRecordsArgs({ filters: { "data.x": 1 } }),
162
+ /resource.*recordSlug/,
163
+ );
164
+ });
165
+
166
+ test("parseLegacyFilters: hostile inputs return undefined, no crash", () => {
167
+ assert.equal(parseLegacyFilters(null), undefined);
168
+ assert.equal(parseLegacyFilters(undefined), undefined);
169
+ assert.equal(parseLegacyFilters([]), undefined);
170
+ assert.equal(parseLegacyFilters("string"), undefined);
171
+ assert.equal(parseLegacyFilters(42), undefined);
172
+ });
173
+
174
+ test("resource takes precedence over recordSlug", () => {
175
+ const out = translateQueryRecordsArgs({ resource: "canonical", recordSlug: "legacy" });
176
+ assert.equal(out.resource, "canonical");
177
+ });