@centrali-io/centrali-mcp 5.5.1 → 6.0.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.
@@ -0,0 +1,497 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { CentraliSDK } from "@centrali-io/centrali-sdk";
3
+ import { z } from "zod";
4
+ import { registerTool, formatError } from "./_register.js";
5
+
6
+ // Tool names use the canonical `*_saved_query` form (CEN-1198 rename:
7
+ // "Smart Queries" → "Saved Queries"). Internally they route through
8
+ // `sdk.savedQueries.*` and accept canonical query bodies. The legacy
9
+ // `*_saved_query` MCP names were dropped before any external consumer
10
+ // shipped — there is no compatibility window.
11
+
12
+ const CANONICAL_QUERY_DEFINITION_HINT = `Canonical QueryDefinition body (saved queries store the inner shape — 'resource' is filled in from the collection slug arg).
13
+
14
+ 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'.
15
+
16
+ Optional clauses authors can include in saved queries: 'where', 'sort', 'page', 'select', and 'joins' (multi-collection joins — see JOINS_HINT below). The 'text' (full-text search) and 'include' (relation expansion) clauses are reserved on saved-query create/update/test surfaces and currently fail with 422 unsupported_clause — they are available on the ad-hoc 'POST /records/query' surface only. Don't author saved queries that use them.
17
+
18
+ Variables are referenced as '\${varName}' inside operator values; declare their types via the 'variables' arg (see VARIABLE_DECLARATION_HINT). On execute_saved_query the server validates each concrete value against the saved query's declarations by JS typeof — there is no coercion, so e.g. '"123"' is rejected for a 'number' declaration; 'datetime' accepts an ISO-8601 string (typed errors: 'variable_type_mismatch', 'missing_required_variable', 'extra_variable'). test_saved_query is unwired for typed dry-run — it falls back to legacy string substitution, so type validation only happens once the query is saved and executed. Example:
19
+ {
20
+ "where": { "data.status": { "eq": "\${statusFilter}" } },
21
+ "sort": [{ "field": "createdAt", "direction": "desc" }],
22
+ "page": { "limit": 100 }
23
+ }
24
+
25
+ Legacy '{{varName}}' placeholders still work on pre-Phase-4 (untyped) saved queries during the deprecation window — every value is stringified before substitution. New saved queries should declare typed 'variables' and use '\${varName}'.
26
+
27
+ 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.`;
28
+
29
+ const JOINS_HINT = `Optional 'joins[]' clause — saved queries may join up to 4 additional collections to the primary record set. Each entry has shape:
30
+ {
31
+ "foreignSlug": "<collection slug>", // required — literal, no \${var} placeholders
32
+ "localField": "data.customerId", // field on primary OR '<priorAlias>.<field>' to chain — literal
33
+ "foreignField": "id", // field on the joined table — literal
34
+ "joinType": "inner" | "left" | "right" | "full", // required — see availability note below
35
+ "select": ["data.name", "data.email"], // optional projection on the joined row
36
+ "alias": "customer" // optional; defaults to foreignSlug. REQUIRED when joining the same foreignSlug twice. Must not equal the primary 'resource' or "_joined". Literal.
37
+ }
38
+
39
+ Cap: 4 joins per query (JOINS_MAX_LENGTH).
40
+
41
+ Field reference rules:
42
+ - 'localField' / 'foreignField' use 'data.<field>' for JSONB-side properties; 'id', 'createdAt', and other top-level columns may be referenced bare.
43
+ - In a chained join, 'localField' may use '<alias>.<field>' to reference a prior join's row (e.g. 'customer.data.regionId').
44
+
45
+ Identifier slots are LITERAL ONLY: 'foreignSlug', 'localField', 'foreignField', 'alias', and 'joinType' cannot contain '\${var}' placeholders. This mirrors standard SQL — parameter binding applies to values, never identifiers. Author-time auth reads the persisted body, so allowing placeholders here would let an executor rebind the join shape at runtime and bypass authorization.
46
+
47
+ JoinType availability:
48
+ - 'inner' and 'left' are always available.
49
+ - 'right' and 'full' are gated server-side by the 'DATA_QUERY_OUTER_JOINS_ENABLED' env flag. When disabled, the executor returns 'unsupported_clause' on those join types. Default for the initial production rollout is disabled — coordinate with ops before authoring queries that depend on RIGHT or FULL OUTER.
50
+
51
+ Constraints:
52
+ - 'text' and 'joins' are mutually exclusive in the same query — combining them is rejected with 'unsupported_combination'.
53
+ - 'include' and 'joins' are mutually exclusive — combining them is rejected with 'unsupported_combination'.
54
+ - Aliases (or foreignSlugs when alias is omitted) must be unique within 'joins[]'; duplicates fail with 'duplicate_join_alias'.
55
+ - Aliases must not collide with the primary 'resource' or the reserved key "_joined" — also surfaced as 'duplicate_join_alias'.
56
+ - Exceeding 4 entries fails with 'joins_length_exceeded'.
57
+
58
+ Authorization: the caller needs 'records:list' on the primary collection AND on every joined collection. The data service enforces this server-side at author time; the LLM should not propose joins to collections the user can't read.
59
+
60
+ Response shape: each row's joined data lands under a top-level '_joined' key in the row, namespaced by alias:
61
+ { "id": "...", "data": { ... }, "_joined": { "customer": { "id": "...", "data": { ... } } | null } }
62
+
63
+ For LEFT joins with no joined-side match, the alias entry is null. For RIGHT and FULL OUTER joins, rows where the PRIMARY side has no match emit as phantom-primary rows: every primary column is null and the joined side carries the unmatched-foreign data:
64
+ { "id": null, "data": null, ..., "_joined": { "customer": { "id": "...", "data": { ... } } } }
65
+
66
+ This is symmetric SQL semantics — the row exists, one side is missing. Consumers detect phantom rows by 'id === null'.`;
67
+
68
+ const VARIABLE_DECLARATION_HINT = `Optional typed parameter declarations for '\${var}' placeholders in the query body. Each entry is keyed by the variable name and has shape:
69
+ { type, required?, default?, description? }
70
+ Where 'type' is one of:
71
+ - 'string' | 'number' | 'boolean' | 'datetime' | 'id'
72
+ - { array: <innerType> } (e.g. { array: 'string' })
73
+ - { reference: '<collectionSlug>' } (e.g. { reference: 'orders' })
74
+ When provided, every '\${var}' placeholder in the query body must be declared, and execute-time values are validated against these types by JS typeof (no coercion). Pass 'null' on update_saved_query to clear declarations and revert to untyped string substitution.`;
75
+
76
+ /**
77
+ * Canonical placeholders use `${var}`. Any string value inside the query
78
+ * containing a placeholder produces a referenced-variable name. Used for
79
+ * best-effort client-side validation in execute_saved_query so the AI gets
80
+ * a tighter error before round-tripping to the server.
81
+ */
82
+ function extractCanonicalVariableNames(definition: unknown): Set<string> {
83
+ const names = new Set<string>();
84
+ const re = /\$\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}/g;
85
+ const visit = (node: unknown): void => {
86
+ if (node == null) return;
87
+ if (typeof node === "string") {
88
+ for (const match of node.matchAll(re)) names.add(match[1]);
89
+ return;
90
+ }
91
+ if (Array.isArray(node)) {
92
+ for (const item of node) visit(item);
93
+ return;
94
+ }
95
+ if (typeof node === "object") {
96
+ for (const value of Object.values(node as Record<string, unknown>)) visit(value);
97
+ }
98
+ };
99
+ visit(definition);
100
+ return names;
101
+ }
102
+
103
+ export function registerSavedQueryTools(server: McpServer, sdk: CentraliSDK) {
104
+ registerTool<any>(server,
105
+ "list_saved_queries",
106
+ "List saved (a.k.a. smart) queries. Saved queries are reusable, parameterized queries defined in the Centrali console. Optionally filter by collection record slug. Each row includes a 'variables' map (typed parameter declarations) when the query was authored with Phase 4 typed parameters.",
107
+ {
108
+ recordSlug: z
109
+ .string()
110
+ .optional()
111
+ .describe(
112
+ "Filter by collection record slug. If omitted, lists all saved queries in the workspace"
113
+ ),
114
+ },
115
+ async ({ recordSlug }) => {
116
+ try {
117
+ const result = recordSlug
118
+ ? await sdk.savedQueries.list(recordSlug)
119
+ : await sdk.savedQueries.listAll();
120
+ return {
121
+ content: [
122
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
123
+ ],
124
+ };
125
+ } catch (error: unknown) {
126
+ return {
127
+ content: [
128
+ {
129
+ type: "text",
130
+ text: formatError(error, "listing saved queries"),
131
+ },
132
+ ],
133
+ isError: true,
134
+ };
135
+ }
136
+ }
137
+ );
138
+
139
+ registerTool<any>(server,
140
+ "execute_saved_query",
141
+ `Execute a saved query by ID and return a { rows, meta, fields } envelope.
142
+
143
+ Saved queries can declare variables referenced as '\${varName}' inside operator values. Pass concrete values via 'variables' — they are validated against the saved query's typed declarations by JS typeof (no coercion; see get_saved_query → 'variables' for the declared shape). Type mismatches surface clear errors from the server (e.g. 'variable_type_mismatch', 'missing_required_variable').
144
+
145
+ Multi-join responses: when the saved query declares 'joins[]', each row in 'rows' carries a top-level '_joined' object keyed by join alias — 'rows[i]._joined': { '<alias>': <joinedRow> | null }. Aliases default to the joined collection's slug; LEFT joins surface 'null' for rows with no match. RIGHT and FULL OUTER joins (when enabled via 'DATA_QUERY_OUTER_JOINS_ENABLED') additionally surface PHANTOM-PRIMARY rows where the primary side has no match — every primary column is null and consumers detect them by 'rows[i].id === null'.`,
146
+ {
147
+ recordSlug: z
148
+ .string()
149
+ .describe("The collection's record slug the query belongs to"),
150
+ queryId: z.string().describe("The saved query ID (UUID) to execute"),
151
+ variables: z
152
+ .record(z.string(), z.any())
153
+ .optional()
154
+ .describe(
155
+ "Values bound to the saved query's '\${varName}' placeholders. Keys must match the declared variables on the saved query; types are validated server-side by JS typeof — no coercion (typed Phase-4 queries) or stringified (legacy untyped queries)."
156
+ ),
157
+ },
158
+ async ({ recordSlug, queryId, variables }) => {
159
+ try {
160
+ // Best-effort client-side validation: fetch the saved query
161
+ // first so we can flag unknown / missing variables before the
162
+ // server round-trip. Server-side validation is still
163
+ // authoritative for type coercion and `variable_type_mismatch`.
164
+ let declared: Record<string, any> | null | undefined;
165
+ let referencedNames: Set<string> | undefined;
166
+ try {
167
+ const fetched = await sdk.savedQueries.get(recordSlug, queryId);
168
+ const row: any = fetched.data ?? {};
169
+ declared = (row.variables ?? null) as Record<string, any> | null;
170
+ referencedNames = extractCanonicalVariableNames(row.queryDefinition ?? row.query ?? {});
171
+ } catch {
172
+ // Permission/network blip — skip preflight and let the
173
+ // execute call surface the canonical error.
174
+ }
175
+
176
+ if (declared && typeof declared === "object") {
177
+ const declaredKeys = new Set(Object.keys(declared));
178
+ const provided = variables ?? {};
179
+ const extras = Object.keys(provided).filter((k) => !declaredKeys.has(k));
180
+ if (extras.length > 0) {
181
+ return {
182
+ content: [
183
+ {
184
+ type: "text",
185
+ text:
186
+ `Error executing saved query '${queryId}': unknown variable(s) ` +
187
+ extras.map((e) => `'${e}'`).join(", ") +
188
+ `. Declared variables: ${Object.keys(declared).join(", ") || "(none)"}.`,
189
+ },
190
+ ],
191
+ isError: true,
192
+ };
193
+ }
194
+ const missing = Object.entries(declared)
195
+ .filter(([name, def]: [string, any]) => def?.required === true && !(name in provided) && def?.default === undefined)
196
+ .map(([name]) => name);
197
+ if (missing.length > 0) {
198
+ return {
199
+ content: [
200
+ {
201
+ type: "text",
202
+ text:
203
+ `Error executing saved query '${queryId}': missing required variable(s) ` +
204
+ missing.map((m) => `'${m}'`).join(", ") + ".",
205
+ },
206
+ ],
207
+ isError: true,
208
+ };
209
+ }
210
+ } else if (referencedNames && referencedNames.size > 0) {
211
+ // Untyped (legacy) query that still references placeholders
212
+ // — flag if no values supplied at all, but don't gate on
213
+ // unknown keys because legacy substitution accepts extras.
214
+ const provided = variables ?? {};
215
+ const missing = Array.from(referencedNames).filter((n) => !(n in provided));
216
+ if (missing.length > 0) {
217
+ return {
218
+ content: [
219
+ {
220
+ type: "text",
221
+ text:
222
+ `Error executing saved query '${queryId}': query references variable(s) ` +
223
+ missing.map((m) => `'${m}'`).join(", ") +
224
+ " but no values were provided. Pass them via the 'variables' arg.",
225
+ },
226
+ ],
227
+ isError: true,
228
+ };
229
+ }
230
+ }
231
+
232
+ const options = variables ? { variables } : undefined;
233
+ const result = await sdk.savedQueries.execute(
234
+ recordSlug,
235
+ queryId,
236
+ options
237
+ );
238
+ return {
239
+ content: [
240
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
241
+ ],
242
+ };
243
+ } catch (error: unknown) {
244
+ return {
245
+ content: [
246
+ {
247
+ type: "text",
248
+ text: formatError(error, `executing saved query '${queryId}'`),
249
+ },
250
+ ],
251
+ isError: true,
252
+ };
253
+ }
254
+ }
255
+ );
256
+
257
+ registerTool<any>(server,
258
+ "get_saved_query",
259
+ "Get a saved query by ID. Returns the full row including canonical 'queryDefinition' (where/sort/page/select/joins), the typed 'variables' declarations (when declared), and audit fields. ('text' and 'include' are reserved on saved-query surfaces — saved queries authored before that constraint may still carry them, but they cannot be re-saved.) Multi-join queries surface their joined-collection chain via 'queryDefinition.joins[]' — each entry shows the foreignSlug, fields, joinType, and optional alias/select.",
260
+ {
261
+ recordSlug: z.string().describe("The collection's record slug the query belongs to"),
262
+ queryId: z.string().describe("The saved query ID (UUID)"),
263
+ },
264
+ async ({ recordSlug, queryId }) => {
265
+ try {
266
+ const result = await sdk.savedQueries.get(recordSlug, queryId);
267
+ return {
268
+ content: [
269
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
270
+ ],
271
+ };
272
+ } catch (error: unknown) {
273
+ return {
274
+ content: [
275
+ {
276
+ type: "text",
277
+ text: formatError(error, `getting saved query '${queryId}'`),
278
+ },
279
+ ],
280
+ isError: true,
281
+ };
282
+ }
283
+ }
284
+ );
285
+
286
+ registerTool<any>(server,
287
+ "create_saved_query",
288
+ `Create a new saved query for a collection.
289
+
290
+ Authorization (SECURITY DEFINER, locked 2026-05-04): saved queries are first-class resources and behave like SQL views — the AUTHOR's permissions define what the query can read; the executor only needs permission to invoke. At AUTHOR time (this call), the caller must hold 'records:list' on the primary collection AND on every collection referenced via 'joins[]'. The data service enforces this server-side and returns 403 if any access is missing. At EXECUTE time (execute_saved_query), only an 'execute' check on the saved-query resource runs — there is no per-collection re-check, and field-level redaction follows the author's permissions, not the executor's. Don't propose queries against collections the user can't read.
291
+
292
+ ${CANONICAL_QUERY_DEFINITION_HINT}
293
+
294
+ ${JOINS_HINT}
295
+
296
+ ${VARIABLE_DECLARATION_HINT}`,
297
+ {
298
+ recordSlug: z.string().describe("The collection's record slug to create the query for"),
299
+ name: z.string().describe("Display name for the saved query"),
300
+ description: z.string().optional().describe("Optional description"),
301
+ queryDefinition: z
302
+ .record(z.string(), z.any())
303
+ .describe(
304
+ "Canonical inner QueryDefinition (without 'resource'). Supported clauses: 'where', 'sort', 'page', 'select', 'joins'. Reference variables as '\${varName}' inside operator values. 'joins' (max 4) enables multi-collection joins — see tool description for the JoinClause shape, identifier-literal rules, and joinType availability ('inner' / 'left' always; 'right' / 'full' gated by 'DATA_QUERY_OUTER_JOINS_ENABLED'). Reserved on this surface (saved queries) and rejected with 422 unsupported_clause: 'text' (full-text search) and 'include' (relation expansion) — they're available on the ad-hoc 'POST /records/query' surface only."
305
+ ),
306
+ variables: z
307
+ .record(z.string(), z.any())
308
+ .optional()
309
+ .describe(
310
+ "Typed parameter declarations keyed by variable name. Shape: { type, required?, default?, description? }. type: 'string'|'number'|'boolean'|'datetime'|'id' | { array: T } | { reference: 'collectionSlug' }. When provided, every '\${var}' in queryDefinition must be declared."
311
+ ),
312
+ },
313
+ async ({ recordSlug, name, description, queryDefinition, variables }) => {
314
+ try {
315
+ const input: Record<string, any> = { name };
316
+ if (description !== undefined) input.description = description;
317
+ if (variables !== undefined) {
318
+ // Typed declarations require the canonical create path,
319
+ // which expects a full QueryDefinition (including
320
+ // 'resource'). `recordSlug` is the URL-level source of
321
+ // truth for the target collection — apply it last so a
322
+ // caller-supplied `queryDefinition.resource` cannot
323
+ // silently retarget the create.
324
+ input.query = { ...(queryDefinition as object), resource: recordSlug };
325
+ input.variables = variables;
326
+ } else {
327
+ // No typed declarations — keep the legacy slug-based path
328
+ // for back-compat with pre-Phase-4 callers.
329
+ input.queryDefinition = queryDefinition;
330
+ }
331
+
332
+ const result = await sdk.savedQueries.create(recordSlug, input as any);
333
+ return {
334
+ content: [
335
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
336
+ ],
337
+ };
338
+ } catch (error: unknown) {
339
+ return {
340
+ content: [
341
+ {
342
+ type: "text",
343
+ text: formatError(error, `creating saved query '${name}' for '${recordSlug}'`),
344
+ },
345
+ ],
346
+ isError: true,
347
+ };
348
+ }
349
+ }
350
+ );
351
+
352
+ registerTool<any>(server,
353
+ "update_saved_query",
354
+ `Update an existing saved query. Only include the fields you want to change.
355
+
356
+ Authorization (SECURITY DEFINER): saved queries follow author-bound permissions — the author's privileges define what the query can read; the executor only needs an 'execute' check on the saved-query resource. When updating 'queryDefinition' to add or change 'joins[]' (or change the primary 'resource'), this call requires 'records:list' on the primary collection AND every newly-referenced joined collection at AUTHOR time. The data service enforces this server-side and returns 403 if any access is missing. At EXECUTE time (execute_saved_query) there is no per-collection re-check, and field-level redaction follows the author's permissions, not the executor's.
357
+
358
+ ${CANONICAL_QUERY_DEFINITION_HINT}
359
+
360
+ ${JOINS_HINT}
361
+
362
+ ${VARIABLE_DECLARATION_HINT}`,
363
+ {
364
+ recordSlug: z.string().describe("The collection's record slug the query belongs to"),
365
+ queryId: z.string().describe("The saved query ID (UUID) to update"),
366
+ name: z.string().optional().describe("Updated display name"),
367
+ description: z.string().optional().describe("Updated description"),
368
+ queryDefinition: z
369
+ .record(z.string(), z.any())
370
+ .optional()
371
+ .describe("Updated canonical inner QueryDefinition (without 'resource'). Supported clauses: 'where', 'sort', 'page', 'select', 'joins' (max 4 — see tool description). Reserved on this surface (saved queries) and rejected with 422 unsupported_clause: 'text' (full-text search) and 'include' (relation expansion) — they're available on the ad-hoc 'POST /records/query' surface only."),
372
+ variables: z
373
+ .union([z.record(z.string(), z.any()), z.null()])
374
+ .optional()
375
+ .describe(
376
+ "Replace the typed parameter declarations. Pass 'null' to clear declarations and revert the query to untyped string substitution. Omit to leave existing declarations untouched. Setting this routes through the canonical update path."
377
+ ),
378
+ },
379
+ async ({ recordSlug, queryId, name, description, queryDefinition, variables }) => {
380
+ try {
381
+ const input: Record<string, any> = {};
382
+ if (name !== undefined) input.name = name;
383
+ if (description !== undefined) input.description = description;
384
+
385
+ if (variables !== undefined) {
386
+ // Canonical update: requires `query` (with resource) when
387
+ // queryDefinition is also being updated; otherwise just
388
+ // ship the variables change. Apply `resource` last so a
389
+ // caller-supplied `queryDefinition.resource` cannot trip
390
+ // the server's "resource cannot change on update" guard.
391
+ if (queryDefinition !== undefined) {
392
+ input.query = { ...(queryDefinition as object), resource: recordSlug };
393
+ }
394
+ input.variables = variables;
395
+ } else if (queryDefinition !== undefined) {
396
+ input.queryDefinition = queryDefinition;
397
+ }
398
+
399
+ const result = await sdk.savedQueries.update(recordSlug, queryId, input as any);
400
+ return {
401
+ content: [
402
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
403
+ ],
404
+ };
405
+ } catch (error: unknown) {
406
+ return {
407
+ content: [
408
+ {
409
+ type: "text",
410
+ text: formatError(error, `updating saved query '${queryId}'`),
411
+ },
412
+ ],
413
+ isError: true,
414
+ };
415
+ }
416
+ }
417
+ );
418
+
419
+ registerTool<any>(server,
420
+ "delete_saved_query",
421
+ "Delete a saved query by ID.",
422
+ {
423
+ recordSlug: z.string().describe("The collection's record slug the query belongs to"),
424
+ queryId: z.string().describe("The saved query ID (UUID) to delete"),
425
+ },
426
+ async ({ recordSlug, queryId }) => {
427
+ try {
428
+ await sdk.savedQueries.delete(recordSlug, queryId);
429
+ return {
430
+ content: [
431
+ {
432
+ type: "text",
433
+ text: `Saved query '${queryId}' deleted from '${recordSlug}'.`,
434
+ },
435
+ ],
436
+ };
437
+ } catch (error: unknown) {
438
+ return {
439
+ content: [
440
+ {
441
+ type: "text",
442
+ text: formatError(error, `deleting saved query '${queryId}'`),
443
+ },
444
+ ],
445
+ isError: true,
446
+ };
447
+ }
448
+ }
449
+ );
450
+
451
+ registerTool<any>(server,
452
+ "test_saved_query",
453
+ `Test execute a query definition without saving it. Useful for validating canonical syntax and previewing results before creating a saved query.
454
+
455
+ ${CANONICAL_QUERY_DEFINITION_HINT}
456
+
457
+ ${JOINS_HINT}
458
+
459
+ Typed dry-run is not yet wired on the server: '/saved-queries/test' resolves '\${var}' placeholders via legacy string substitution and does not honor typed 'variableDeclarations'. Use create_saved_query + execute_saved_query (or update_saved_query) to validate typed parameters end-to-end.`,
460
+ {
461
+ recordSlug: z.string().describe("The collection's record slug to test against"),
462
+ queryDefinition: z
463
+ .record(z.string(), z.any())
464
+ .describe("Canonical inner QueryDefinition to test (without 'resource'). Supported clauses: 'where', 'sort', 'page', 'select', 'joins' (max 4 — see tool description). Reserved on this surface (saved-query test) and rejected with 422 unsupported_clause: 'text' and 'include' — they're available on the ad-hoc 'POST /records/query' surface only."),
465
+ variables: z
466
+ .record(z.string(), z.any())
467
+ .optional()
468
+ .describe("Optional runtime values bound to '\${var}' placeholders. Substituted via legacy string substitution on the test path (every value is stringified)."),
469
+ },
470
+ async ({ recordSlug, queryDefinition, variables }) => {
471
+ try {
472
+ const input: Record<string, any> = { queryDefinition };
473
+ if (variables !== undefined) input.variables = variables;
474
+
475
+ const result = await sdk.savedQueries.test(recordSlug, input as any);
476
+ return {
477
+ content: [
478
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
479
+ ],
480
+ };
481
+ } catch (error: unknown) {
482
+ return {
483
+ content: [
484
+ {
485
+ type: "text",
486
+ text: formatError(error, `test-executing saved query for '${recordSlug}'`),
487
+ },
488
+ ],
489
+ isError: true,
490
+ };
491
+ }
492
+ }
493
+ );
494
+ }
495
+
496
+ // Exposed for tests
497
+ export const _internal = { extractCanonicalVariableNames };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Regression tests for `registerSavedQueryTools` request shaping.
3
+ *
4
+ * Covers:
5
+ * - create / update inject `resource` from the URL `recordSlug` and
6
+ * override any caller-supplied `queryDefinition.resource` (otherwise
7
+ * a tool call could silently retarget the saved query to a different
8
+ * collection, or trip the server's "resource cannot change on update"
9
+ * guard).
10
+ * - test_saved_query no longer ships `variableDeclarations` to the
11
+ * server (the canonical `/saved-queries/test` route does not honor
12
+ * typed dry-run today; advertising it would be a lie).
13
+ *
14
+ * Uses a hand-rolled fake `McpServer` + `CentraliSDK` so we can assert
15
+ * exactly what `sdk.savedQueries.*` was called with.
16
+ */
17
+ const test = require("node:test");
18
+ const assert = require("node:assert/strict");
19
+
20
+ const { registerSavedQueryTools } = require("../dist/tools/saved-queries.js");
21
+
22
+ function makeFakeServer() {
23
+ const handlers = new Map();
24
+ return {
25
+ handlers,
26
+ tool: (name, _desc, _schema, handler) => {
27
+ handlers.set(name, handler);
28
+ },
29
+ };
30
+ }
31
+
32
+ function makeFakeSdk() {
33
+ const calls = [];
34
+ return {
35
+ calls,
36
+ savedQueries: {
37
+ create: async (recordSlug, input) => {
38
+ calls.push({ method: "create", recordSlug, input });
39
+ return { data: { id: "fake-id", recordSlug, ...input } };
40
+ },
41
+ update: async (recordSlug, queryId, input) => {
42
+ calls.push({ method: "update", recordSlug, queryId, input });
43
+ return { data: { id: queryId, recordSlug, ...input } };
44
+ },
45
+ test: async (recordSlug, input) => {
46
+ calls.push({ method: "test", recordSlug, input });
47
+ return { data: [] };
48
+ },
49
+ get: async (recordSlug, queryId) => {
50
+ calls.push({ method: "get", recordSlug, queryId });
51
+ throw new Error("no preflight in routing tests");
52
+ },
53
+ execute: async (recordSlug, queryId, options) => {
54
+ calls.push({ method: "execute", recordSlug, queryId, options });
55
+ return { data: { result: [] } };
56
+ },
57
+ },
58
+ };
59
+ }
60
+
61
+ function setup() {
62
+ const server = makeFakeServer();
63
+ const sdk = makeFakeSdk();
64
+ registerSavedQueryTools(server, sdk);
65
+ return { server, sdk };
66
+ }
67
+
68
+ test("create: recordSlug overrides caller-supplied queryDefinition.resource", async () => {
69
+ const { server, sdk } = setup();
70
+ const handler = server.handlers.get("create_saved_query");
71
+ await handler({
72
+ recordSlug: "orders",
73
+ name: "Active Orders",
74
+ queryDefinition: {
75
+ resource: "wrong-collection",
76
+ where: { "data.status": { eq: "${status}" } },
77
+ },
78
+ variables: { status: { type: "string", required: true } },
79
+ });
80
+ const call = sdk.calls.find((c) => c.method === "create");
81
+ assert.ok(call, "expected create call");
82
+ assert.equal(call.input.query.resource, "orders");
83
+ // The whole-object spread keeps the rest of the body intact.
84
+ assert.deepEqual(call.input.query.where, { "data.status": { eq: "${status}" } });
85
+ assert.deepEqual(call.input.variables, { status: { type: "string", required: true } });
86
+ });
87
+
88
+ test("create: legacy path used when no variables declared", async () => {
89
+ const { server, sdk } = setup();
90
+ const handler = server.handlers.get("create_saved_query");
91
+ await handler({
92
+ recordSlug: "orders",
93
+ name: "Legacy",
94
+ queryDefinition: { where: { "data.status": { eq: "open" } } },
95
+ });
96
+ const call = sdk.calls.find((c) => c.method === "create");
97
+ assert.ok(call.input.queryDefinition, "legacy field present");
98
+ assert.equal(call.input.query, undefined, "no canonical query field");
99
+ });
100
+
101
+ test("update: recordSlug overrides caller-supplied queryDefinition.resource", async () => {
102
+ const { server, sdk } = setup();
103
+ const handler = server.handlers.get("update_saved_query");
104
+ await handler({
105
+ recordSlug: "orders",
106
+ queryId: "qid-123",
107
+ queryDefinition: {
108
+ resource: "wrong-collection",
109
+ where: { "data.status": { eq: "${status}" } },
110
+ },
111
+ variables: { status: { type: "string" } },
112
+ });
113
+ const call = sdk.calls.find((c) => c.method === "update");
114
+ assert.ok(call, "expected update call");
115
+ assert.equal(call.input.query.resource, "orders");
116
+ });
117
+
118
+ test("update: variables-only edit ships {variables} without query", async () => {
119
+ const { server, sdk } = setup();
120
+ const handler = server.handlers.get("update_saved_query");
121
+ await handler({
122
+ recordSlug: "orders",
123
+ queryId: "qid-123",
124
+ variables: null,
125
+ });
126
+ const call = sdk.calls.find((c) => c.method === "update");
127
+ assert.equal(call.input.query, undefined, "no canonical query rewrite");
128
+ assert.equal(call.input.variables, null, "variables cleared");
129
+ });
130
+
131
+ test("test_saved_query: never ships variableDeclarations (server doesn't honor it yet)", async () => {
132
+ const { server, sdk } = setup();
133
+ const handler = server.handlers.get("test_saved_query");
134
+ await handler({
135
+ recordSlug: "orders",
136
+ queryDefinition: { where: { "data.status": { eq: "${status}" } } },
137
+ variables: { status: "open" },
138
+ // Even if a stale client passes variableDeclarations, the MCP
139
+ // schema doesn't declare it, so the handler never sees it. This
140
+ // assertion is defense-in-depth: ensure no code path forwards it.
141
+ variableDeclarations: { status: { type: "string", required: true } },
142
+ });
143
+ const call = sdk.calls.find((c) => c.method === "test");
144
+ assert.equal(call.input.variableDeclarations, undefined);
145
+ // Stays on the legacy slug-based test endpoint (no canonical `query`).
146
+ assert.equal(call.input.query, undefined);
147
+ assert.ok(call.input.queryDefinition);
148
+ });