@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.
package/README.md CHANGED
@@ -90,7 +90,7 @@ After connecting, call `describe_centrali` first — it returns the full capabil
90
90
  | `describe_records` | Schema reference for record operations (filters, sorting, pagination, expand) |
91
91
  | `describe_search` | Schema reference for full-text search |
92
92
  | `describe_compute` | Compute function reference (input contract, secrets, async execution, api object) |
93
- | `describe_smart_queries` | Smart query reference (parameterized queries, variables) |
93
+ | `describe_saved_queries` | Saved-query schema reference (parameterized queries, variables) |
94
94
  | `describe_orchestrations` | Orchestration reference (multi-step workflows, encrypted params) |
95
95
  | `describe_insights` | Anomaly insights reference |
96
96
  | `describe_validation` | Data validation reference |
@@ -154,19 +154,113 @@ After connecting, call `describe_centrali` first — it returns the full capabil
154
154
  | `invoke_endpoint` | Call a sync compute endpoint by path (returns response inline) |
155
155
  | `remove_allowed_domain` | Remove a domain from the allowlist |
156
156
 
157
- ### Saved Queries (a.k.a. Smart Queries)
157
+ ### Saved Queries
158
158
 
159
- Saved queries store a canonical `QueryDefinition` plus optional variables. Tool names keep the `_smart_query` suffix for backwards compatibility, but inputs and outputs are the canonical shape.
159
+ Saved queries store a canonical `QueryDefinition` plus optional variables and an optional multi-collection `joins[]` clause.
160
160
 
161
161
  | Tool | Description |
162
162
  |------|-------------|
163
- | `list_smart_queries` | List saved queries, optionally by collection |
164
- | `get_smart_query` | Get a saved query by ID |
165
- | `create_smart_query` | Create a reusable parameterized query (canonical body) |
166
- | `update_smart_query` | Update a saved query (canonical body) |
167
- | `delete_smart_query` | Delete a saved query |
168
- | `execute_smart_query` | Execute a saved query with optional variables |
169
- | `test_smart_query` | Test a canonical query definition without saving |
163
+ | `list_saved_queries` | List saved queries, optionally by collection |
164
+ | `get_saved_query` | Get a saved query by ID |
165
+ | `create_saved_query` | Create a reusable parameterized query (canonical body) |
166
+ | `update_saved_query` | Update a saved query (canonical body) |
167
+ | `delete_saved_query` | Delete a saved query |
168
+ | `execute_saved_query` | Execute a saved query with optional variables |
169
+ | `test_saved_query` | Test a canonical query definition without saving |
170
+
171
+ **Typed parameter discovery → execute.** Saved queries authored with typed parameters expose their parameter shape through `describe_saved_queries` (schema reference) and `get_saved_query` / `list_saved_queries` (per-row `variables` declarations). The expected discover-then-execute flow:
172
+
173
+ 1. **Discover schema** (one-shot) — `describe_saved_queries` returns the `QueryVariableDefinition` shape (`type`, `required?`, `default?`, `description?`) and the supported `VariableType` union (`string` | `number` | `boolean` | `datetime` | `id` | `{ array: T }` | `{ reference: collectionSlug }`).
174
+ 2. **Discover the query** — `get_saved_query` returns the row including `variables`. Read each entry to learn what to bind. The canonical response includes `queryDefinition.resource` (always equal to the row's `recordSlug`):
175
+ ```json
176
+ {
177
+ "id": "…",
178
+ "name": "Monthly revenue",
179
+ "recordSlug": "orders",
180
+ "queryDefinition": {
181
+ "resource": "orders",
182
+ "where": { "and": [
183
+ { "data.month": { "eq": "${month}" } },
184
+ { "data.region": { "eq": "${region}" } }
185
+ ]},
186
+ "sort": [{ "field": "data.amount", "direction": "desc" }],
187
+ "page": { "limit": 100 }
188
+ },
189
+ "variables": {
190
+ "month": { "type": "datetime", "required": true, "description": "Month bucket (UTC)" },
191
+ "region": { "type": "string", "required": true }
192
+ }
193
+ }
194
+ ```
195
+ 3. **Execute typed** — pass concrete values via `execute_saved_query`'s `variables` arg. The server validates each value against its declared type (no coercion — `"123"` will not be accepted for a `number` declaration) and then substitutes. `datetime` accepts an ISO-8601 string:
196
+ ```json
197
+ {
198
+ "recordSlug": "orders",
199
+ "queryId": "…",
200
+ "variables": { "month": "2026-04-01T00:00:00Z", "region": "us-east" }
201
+ }
202
+ ```
203
+ Typed errors surface as `variable_type_mismatch`, `missing_required_variable`, or `extra_variable`. Untyped (legacy) rows have `variables: null` and accept any keys; values are stringified before substitution. The untyped fallback is also taken by post-backfill rows (canonical body, no declarations yet) until an author promotes them.
204
+
205
+ The MCP `test_saved_query` tool intentionally does **not** expose `variableDeclarations` — its dry-run path falls back to legacy string substitution and does not enforce typed declarations. Round-trip via `create_saved_query` + `execute_saved_query` to validate typed parameters end-to-end.
206
+
207
+ **Multi-collection joins.** Saved queries support an optional `joins[]` clause inside `queryDefinition` (max 4 entries). Each entry joins another collection to the primary record set:
208
+
209
+ ```json
210
+ {
211
+ "resource": "orders",
212
+ "where": { "data.status": { "eq": "open" } },
213
+ "joins": [
214
+ {
215
+ "foreignSlug": "customers",
216
+ "localField": "data.customerId",
217
+ "foreignField": "id",
218
+ "joinType": "left",
219
+ "select": ["id", "data.name", "data.email"],
220
+ "alias": "customer"
221
+ }
222
+ ],
223
+ "sort": [{ "field": "createdAt", "direction": "desc" }],
224
+ "page": { "limit": 50 }
225
+ }
226
+ ```
227
+
228
+ Constraints (validated by `@centrali/query`):
229
+
230
+ - `joinType` must be `'inner'` or `'left'` — `'right'` and `'full'` are rejected by the executor with `unsupported_clause`.
231
+ - `text` and `joins` cannot appear in the same query (`unsupported_combination`).
232
+ - Aliases (or implicit `foreignSlug`) must be unique within `joins[]` (`duplicate_join_alias`).
233
+ - Cap of 4 entries (`joins_length_exceeded`).
234
+ - `localField` may use `<priorAlias>.<field>` to chain a join through an earlier alias.
235
+
236
+ `execute_saved_query` returns `{ rows, meta, fields }`. Each entry in `rows` carries joined data under a top-level `_joined` key:
237
+
238
+ ```json
239
+ {
240
+ "rows": [
241
+ {
242
+ "id": "order-123",
243
+ "data": { "status": "open" },
244
+ "_joined": {
245
+ "customer": { "id": "cust-1", "data": { "name": "Acme" } }
246
+ }
247
+ }
248
+ ],
249
+ "meta": { "limit": 50, "offset": 0, "total": 1 },
250
+ "fields": [{ "name": "id" }, { "name": "data" }, { "name": "_joined" }]
251
+ }
252
+ ```
253
+
254
+ LEFT joins surface `null` for unmatched rows.
255
+
256
+ **Authorization (SECURITY DEFINER, locked 2026-05-04).** Saved queries are author-bound — the AUTHOR's permissions define what the query can read; the executor only needs an `execute` check on the saved-query resource at runtime.
257
+
258
+ - **Author time** (`create_saved_query` / `update_saved_query` / `test_saved_query`): the caller must hold `records:list` on the primary collection AND on every collection referenced in `joins[]`. The data service returns 403 if any access is missing.
259
+ - **Execute time** (`execute_saved_query`): only `execute` on the saved-query resource is checked; there is no per-collection re-check, and field-level redaction follows the author's permissions, not the executor's. Secret-masking still applies unconditionally.
260
+
261
+ AI assistants should not propose saved queries (or join chains) against collections the **author** can't read — execute will succeed for less-privileged callers, but authoring will fail.
262
+
263
+ **Reserved clauses.** Saved-query create/update/test surfaces reject `text` (full-text search) and `include` (relation expansion) with 422 `unsupported_clause`. Those clauses are available on the ad-hoc `POST /records/query` surface only — don't author saved queries that use them.
170
264
 
171
265
  ### Orchestrations
172
266
  | Tool | Description |
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ const structures_js_1 = require("./tools/structures.js");
17
17
  const records_js_1 = require("./tools/records.js");
18
18
  const search_js_1 = require("./tools/search.js");
19
19
  const compute_js_1 = require("./tools/compute.js");
20
- const smart_queries_js_1 = require("./tools/smart-queries.js");
20
+ const saved_queries_js_1 = require("./tools/saved-queries.js");
21
21
  const orchestrations_js_1 = require("./tools/orchestrations.js");
22
22
  const insights_js_1 = require("./tools/insights.js");
23
23
  const validation_js_1 = require("./tools/validation.js");
@@ -73,7 +73,7 @@ function main() {
73
73
  (0, records_js_1.registerRecordTools)(server, sdk, baseUrl, workspaceId);
74
74
  (0, search_js_1.registerSearchTools)(server, sdk);
75
75
  (0, compute_js_1.registerComputeTools)(server, sdk, baseUrl, workspaceId);
76
- (0, smart_queries_js_1.registerSmartQueryTools)(server, sdk);
76
+ (0, saved_queries_js_1.registerSavedQueryTools)(server, sdk);
77
77
  (0, orchestrations_js_1.registerOrchestrationTools)(server, sdk);
78
78
  (0, insights_js_1.registerInsightTools)(server, sdk);
79
79
  (0, validation_js_1.registerValidationTools)(server, sdk);
@@ -90,16 +90,16 @@ function registerDescribeTools(server) {
90
90
  ],
91
91
  },
92
92
  smart_queries: {
93
- summary: "Reusable, parameterized queries defined in the console. Support {{variable}} substitution.",
94
- describeWith: "describe_smart_queries",
93
+ summary: "Reusable, parameterized queries defined in the console. Support typed '${variable}' substitution and multi-collection joins (up to 4 joined collections per query).",
94
+ describeWith: "describe_saved_queries",
95
95
  tools: [
96
- "list_smart_queries",
97
- "get_smart_query",
98
- "create_smart_query",
99
- "update_smart_query",
100
- "delete_smart_query",
101
- "execute_smart_query",
102
- "test_smart_query",
96
+ "list_saved_queries",
97
+ "get_saved_query",
98
+ "create_saved_query",
99
+ "update_saved_query",
100
+ "delete_saved_query",
101
+ "execute_saved_query",
102
+ "test_saved_query",
103
103
  ],
104
104
  },
105
105
  orchestrations: {
@@ -985,30 +985,84 @@ function registerDescribeTools(server) {
985
985
  ],
986
986
  });
987
987
  }));
988
- // ── Smart Queries ──────────────────────────────────────────────────
989
- (0, _register_js_1.registerTool)(server, "describe_smart_queries", "Get the schema reference for Centrali saved (a.k.a. smart) queries. Explains the canonical QueryDefinition body, variable substitution, and execution.", {}, () => __awaiter(this, void 0, void 0, function* () {
988
+ // ── Saved Queries ──────────────────────────────────────────────────
989
+ (0, _register_js_1.registerTool)(server, "describe_saved_queries", "Get the schema reference for Centrali saved queries. Explains the canonical QueryDefinition body, variable substitution, and execution.", {}, () => __awaiter(this, void 0, void 0, function* () {
990
990
  return ({
991
991
  content: [
992
992
  {
993
993
  type: "text",
994
994
  text: JSON.stringify({
995
- domain: "Saved Queries (a.k.a. Smart Queries)",
996
- description: "Saved queries are reusable, parameterized queries defined in the Centrali console. They store a canonical QueryDefinition plus optional variable declarations and are executed under the caller's permissions. Tool names retain the `_smart_query` suffix for backwards compatibility.",
995
+ domain: "Saved Queries",
996
+ description: "Saved queries are reusable, parameterized queries defined in the Centrali console. They store a canonical QueryDefinition plus optional typed variable declarations and follow SECURITY DEFINER auth — the AUTHOR's permissions define what the query can read; the executor only needs an 'execute' check on the saved-query resource.",
997
997
  saved_query_shape: {
998
998
  id: "UUID",
999
999
  name: "string — display name",
1000
1000
  recordSlug: "string — the collection this query targets",
1001
1001
  description: "string | null",
1002
- queryDefinition: "object — canonical QueryDefinition (without 'resource' — filled in from recordSlug). Variables are referenced as '{{varName}}' placeholders inside operator values; the engine infers them from the body.",
1002
+ queryDefinition: "object — canonical QueryDefinition (without 'resource' — filled in from recordSlug). Supports 'where', 'sort', 'page', 'select', and a multi-collection 'joins[]' clause (max 4). 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. Variables are referenced as '${varName}' placeholders inside operator values.",
1003
+ variables: "Record<string, QueryVariableDefinition> | null — typed parameter declarations. Present on Phase 4 saved queries; null/absent on legacy untyped rows. See 'variable_declarations' below.",
1004
+ },
1005
+ query_definition_reference: "Use describe_records for the full QueryDefinition shape, operator vocabulary, and examples. Saved queries support 'where', 'sort', 'page', 'select', and a multi-collection 'joins[]' clause (see 'joins' below). Saved queries do NOT support 'text' or 'include' on this surface — those land on ad-hoc 'POST /records/query'.",
1006
+ joins: {
1007
+ description: "Multi-collection joins. Saved queries may include up to 4 joined collections (JOINS_MAX_LENGTH = 4). Each side is sourced from a tenancy-filtered subquery before the join, so wrong-tenant rows can never participate. Combining 'text' and 'joins' is rejected with 'unsupported_combination'; combining 'include' and 'joins' is also rejected with 'unsupported_combination'.",
1008
+ cap: 4,
1009
+ join_clause_shape: {
1010
+ foreignSlug: "string — the joined collection's record slug. LITERAL ONLY: '${var}' placeholders rejected (identifiers must be literal under SECURITY DEFINER auth).",
1011
+ localField: "string — field on primary; bare names ('id') target top-level columns, dotted ('data.customerId') targets JSONB. Chained joins may use '<priorAlias>.<field>' to reference a prior join's row. LITERAL ONLY: no '${var}' placeholders.",
1012
+ foreignField: "string — field on the joined collection (same dotted-vs-bare rules as localField). LITERAL ONLY: no '${var}' placeholders.",
1013
+ joinType: "'inner' | 'left' | 'right' | 'full' — required, no default. INNER and LEFT are always available. RIGHT and FULL OUTER are gated server-side by the 'DATA_QUERY_OUTER_JOINS_ENABLED' env flag; when off the executor returns 'unsupported_clause'. LITERAL ONLY: no '${var}' placeholders.",
1014
+ select: "string[] (optional) — projection on the joined row; defaults to all readable fields",
1015
+ alias: "string (optional) — logical alias used in '_joined[alias]' on response rows and as the 'priorAlias' for chained joins. Defaults to foreignSlug. REQUIRED when joining the same foreignSlug more than once in the same query. Must NOT equal the primary 'resource' or '_joined' (validator rejects with 'duplicate_join_alias'). LITERAL ONLY: no '${var}' placeholders.",
1016
+ },
1017
+ authorization: "SECURITY DEFINER (author-bound). Saved queries follow the SQL-view pattern — the AUTHOR's permissions define what the query can read; the executor only needs an 'execute' check on the saved-query resource at runtime. At AUTHOR time (create / update / test) the caller needs 'records:list' on the primary collection AND every collection referenced in 'joins[]'; the data service returns 403 if any access is missing. At EXECUTE time there is no per-collection re-check, and field-level redaction follows the author's permissions, not the executor's. AI assistants should not propose joined queries against collections the user can't read.",
1018
+ error_codes: [
1019
+ "joins_length_exceeded — more than 4 join entries",
1020
+ "duplicate_join_alias — same alias appears twice, OR alias equals primary 'resource', OR alias equals reserved '_joined'",
1021
+ "unsupported_combination — 'text' + 'joins', or 'include' + 'joins'",
1022
+ "unsupported_clause — joinType is 'right' or 'full' and 'DATA_QUERY_OUTER_JOINS_ENABLED' is off",
1023
+ "invalid_query — '${var}' placeholder in any identifier slot (foreignSlug / localField / foreignField / alias / joinType)",
1024
+ ],
1025
+ response_shape: "execute_saved_query returns '{ rows, meta, fields }'. Each entry in 'rows' carries '_joined': { [alias]: <joinedRow> | null } at the top level. LEFT and FULL joins surface 'null' for the joined alias when there is no match (phantom-foreign). RIGHT and FULL joins additionally surface PHANTOM-PRIMARY rows where the primary side has no match — every primary column ('id', 'data', 'createdAt', ...) is null and the joined alias carries the unmatched-foreign data. Consumers detect phantom rows by 'rows[i].id === null'.",
1026
+ example: {
1027
+ resource: "orders",
1028
+ where: { "data.status": { eq: "open" } },
1029
+ joins: [
1030
+ {
1031
+ foreignSlug: "customers",
1032
+ localField: "data.customerId",
1033
+ foreignField: "id",
1034
+ joinType: "left",
1035
+ select: ["id", "data.name", "data.email"],
1036
+ alias: "customer",
1037
+ },
1038
+ {
1039
+ foreignSlug: "regions",
1040
+ localField: "customer.data.regionId",
1041
+ foreignField: "id",
1042
+ joinType: "inner",
1043
+ alias: "region",
1044
+ },
1045
+ ],
1046
+ sort: [{ field: "createdAt", direction: "desc" }],
1047
+ page: { limit: 50 },
1048
+ },
1049
+ example_response_row: {
1050
+ id: "order-123",
1051
+ data: { status: "open", amount: 99.99 },
1052
+ createdAt: "2026-04-01T00:00:00Z",
1053
+ _joined: {
1054
+ customer: { id: "cust-1", data: { name: "Acme", email: "a@b.co" } },
1055
+ region: { id: "reg-1", data: { code: "us-east" } },
1056
+ },
1057
+ },
1003
1058
  },
1004
- query_definition_reference: "Use describe_records for the full QueryDefinition shape, operator vocabulary, and examples.",
1005
1059
  variable_syntax: {
1006
- description: "Variables use double-brace syntax: '{{variableName}}'. They appear inside operator values; the engine infers the variable list from the body and resolves substitutions at execution time. There is no separate variable declaration placeholders in the QueryDefinition are the source of truth.",
1060
+ description: "Phase 4 canonical syntax: '${variableName}'. Placeholders appear inside operator values; the server resolves substitutions and (when the row carries 'variables' declarations) validates each value against its declared type by JS typeof — there is no server-side coercion, so '\"123\"' is rejected for a 'number' declaration. 'datetime' accepts an ISO-8601 string. Legacy '{{varName}}' placeholders still work on pre-Phase-4 untyped rows during the deprecation window — every value is stringified before substitution. New saved queries should declare typed 'variables' and use '${varName}'.",
1007
1061
  example_query_definition: {
1008
1062
  where: {
1009
1063
  and: [
1010
- { "data.status": { eq: "{{statusFilter}}" } },
1011
- { "data.amount": { gte: "{{minAmount}}" } },
1064
+ { "data.status": { eq: "${statusFilter}" } },
1065
+ { "data.amount": { gte: "${minAmount}" } },
1012
1066
  ],
1013
1067
  },
1014
1068
  sort: [{ field: "createdAt", direction: "desc" }],
@@ -1018,45 +1072,67 @@ function registerDescribeTools(server) {
1018
1072
  statusFilter: "active",
1019
1073
  minAmount: 1000,
1020
1074
  },
1021
- note: "Pass values via the 'variables' arg on execute_smart_query / test_smart_query. Values are substituted before the engine validates the query.",
1075
+ note: "Pass values via the 'variables' arg on execute_saved_query the server validates against the saved query's declarations by JS typeof (no coercion; typed errors: 'variable_type_mismatch', 'missing_required_variable', 'extra_variable'). test_saved_query is unwired for typed dry-run today: it falls back to legacy string substitution and does not enforce typed declarations. Round-trip via create_saved_query + execute_saved_query for typed validation.",
1076
+ },
1077
+ variable_declarations: {
1078
+ description: "Each entry in the saved query's 'variables' map declares a typed parameter slot. Authors set this on create_saved_query / update_saved_query; the executor uses it to validate runtime values by JS typeof (no coercion).",
1079
+ shape: {
1080
+ type: "VariableType — 'string' | 'number' | 'boolean' | 'datetime' | 'id' | { array: VariableType } | { reference: '<collectionSlug>' }",
1081
+ required: "boolean (optional) — when true, the value must be supplied at execute time (or a 'default' must be set)",
1082
+ default: "scalar (optional) — used when the caller omits the value",
1083
+ description: "string (optional) — surfaced in tool descriptions and AI prompts",
1084
+ },
1085
+ example: {
1086
+ statusFilter: { type: "string", required: true, description: "Order status to filter by" },
1087
+ minAmount: { type: "number", default: 0 },
1088
+ since: { type: "datetime", required: true },
1089
+ customerIds: { type: { array: "id" } },
1090
+ ownerRef: { type: { reference: "users" } },
1091
+ },
1092
+ clearing_declarations: "Pass 'variables: null' on update_saved_query to clear declarations and revert the query to untyped string substitution.",
1022
1093
  },
1023
1094
  crud_tools: {
1024
- get_smart_query: {
1025
- description: "Get a saved query by ID, including its full canonical definition",
1095
+ get_saved_query: {
1096
+ description: "Get a saved query by ID, including its full canonical definition and typed 'variables' declarations.",
1026
1097
  required_params: ["recordSlug", "queryId"],
1027
1098
  },
1028
- create_smart_query: {
1029
- description: "Create a new saved query for a collection",
1099
+ create_saved_query: {
1100
+ description: "Create a new saved query for a collection. When 'variables' is provided, the canonical create path is used and every '${var}' in queryDefinition must be declared.",
1030
1101
  required_params: ["recordSlug", "name", "queryDefinition"],
1031
- optional_params: ["description"],
1102
+ optional_params: ["description", "variables"],
1032
1103
  queryDefinition_example: {
1033
- where: { "data.status": { eq: "active" } },
1104
+ where: { "data.status": { eq: "${statusFilter}" } },
1034
1105
  sort: [{ field: "createdAt", direction: "desc" }],
1035
1106
  page: { limit: 100 },
1036
1107
  },
1108
+ variables_example: {
1109
+ statusFilter: { type: "string", required: true },
1110
+ },
1037
1111
  },
1038
- update_smart_query: {
1039
- description: "Update an existing saved query. Partial updates supported.",
1112
+ update_saved_query: {
1113
+ description: "Update an existing saved query. Partial updates supported. Pass 'variables: null' to clear typed declarations.",
1040
1114
  required_params: ["recordSlug", "queryId"],
1041
- optional_params: ["name", "description", "queryDefinition"],
1115
+ optional_params: ["name", "description", "queryDefinition", "variables"],
1042
1116
  },
1043
- delete_smart_query: {
1117
+ delete_saved_query: {
1044
1118
  description: "Delete a saved query by ID",
1045
1119
  required_params: ["recordSlug", "queryId"],
1046
1120
  },
1047
- test_smart_query: {
1048
- description: "Test execute a canonical query definition without saving it. Preview results before creating.",
1121
+ test_saved_query: {
1122
+ description: "Test execute a canonical query definition without saving it. Note: typed 'variableDeclarations' dry-run is not yet wired on the server — use create_saved_query + execute_saved_query to validate typed parameters end-to-end.",
1049
1123
  required_params: ["recordSlug", "queryDefinition"],
1050
1124
  optional_params: ["variables"],
1051
1125
  },
1052
1126
  },
1053
1127
  tips: [
1054
- "Use list_smart_queries to discover available queries (optionally filter by collection slug)",
1055
- "Saved queries return the same canonical { data, meta } envelope as query_records",
1056
- "Variables are declared when creating the query — read the query's 'variables' field to see what's expected",
1128
+ "Use list_saved_queries to discover available queries (optionally filter by collection slug); each row's 'variables' field tells you what to pass",
1129
+ "execute_saved_query returns a { rows, meta, fields } envelope; joined data lives under 'rows[i]._joined' (LEFT/FULL surface 'null' under unmatched aliases; RIGHT/FULL also surface phantom-primary rows where rows[i].id === null and the joined alias carries the foreign-side data). The ad-hoc query_records tool returns the canonical { data, meta } shape they are not interchangeable",
1130
+ "Read the saved query's 'variables' map for type, required-ness, default, and description before calling execute_saved_query",
1057
1131
  "Prefer saved queries over ad-hoc query_records filters for reusable logic",
1058
- "Use test_smart_query to validate canonical syntax and preview results before saving",
1059
- "DO NOT author new saved queries with legacy '$'-prefixed operators ('$eq', '$gte', …). The data service still translates them server-side during the deprecation window, but new saved queries must use canonical operators ('eq', 'gte', …)",
1132
+ "test_saved_query previews canonical syntax + result rows but does not yet enforce typed declarations server-side — round-trip via create + execute_saved_query for typed validation",
1133
+ "DO NOT author new saved queries with legacy '$'-prefixed operators ('$eq', '$gte', …) or '{{var}}' placeholders. Both still translate during the deprecation window, but new saved queries must use canonical operators ('eq', 'gte', …) and '${var}' placeholders.",
1134
+ "For multi-collection reporting, use 'joins[]' (see top-level 'joins' field) — up to 4 joined collections; joinType is 'inner' | 'left' | 'right' | 'full' (RIGHT/FULL gated server-side by 'DATA_QUERY_OUTER_JOINS_ENABLED' — coordinate with ops if you need them). Joined rows arrive under '_joined[alias]' on each primary row. Identifiers ('foreignSlug', 'localField', 'foreignField', 'alias', 'joinType') are literal-only — no '${var}' placeholders. 'text' and 'joins' cannot be combined; 'include' and 'joins' cannot be combined either.",
1135
+ "Before authoring a join, confirm the user has 'records:list' on every joined collection — saved queries run under SECURITY DEFINER and the data service enforces author-time auth on the primary + every join.",
1060
1136
  ],
1061
1137
  }, null, 2),
1062
1138
  },
@@ -1540,12 +1616,12 @@ function registerDescribeTools(server) {
1540
1616
  "Set access policy with set_page_access_policy before publishing if you need auth",
1541
1617
  "Use variable bindings on data sources to scope data dynamically — bind to URL params, auth context, record context, or static defaults",
1542
1618
  "For list→detail navigation: set useQueryParams:true on navigate-to-page actions, then bind the detail page's data source variables to { source: 'url', param: 'id' }. Use config.paramMapping to rename fields if source and target use different names (e.g., { requestId: 'id' })",
1543
- "For user-scoped views (e.g., My Approvals): bind a variable to { source: 'auth', field: 'userId' } and use a smart query with {{assigneeId}}",
1619
+ "For user-scoped views (e.g., My Approvals): bind a variable to { source: 'auth', field: 'userId' } and use a smart query with '${assigneeId}'",
1544
1620
  ],
1545
1621
  multi_page_pattern: {
1546
1622
  description: "How to build a list→detail app with scoped related data",
1547
1623
  steps: [
1548
- "1. Create a smart query with {{variables}} for the detail page's related data (e.g., filter by {{requestId}})",
1624
+ "1. Create a smart query with '${variables}' for the detail page's related data (e.g., filter by '${requestId}')",
1549
1625
  "2. Create a list page with a data-table block and a navigate-to-page action: set config.useQueryParams:true, paramConfig: { source:'row', mode:'selected', selectedFields:['id'] }",
1550
1626
  "3. Create a detail page with: (a) a record-card block with mode:'single' and variables: { id: { source:'url', param:'id' } }, (b) a related-list block with dataSource type:'query', ref:smartQueryId, variables: { requestId: { source:'url', param:'id' } }",
1551
1627
  "4. Publish both pages. Clicking a row on the list navigates to /ws/detail-slug?id=rowId, and the detail page scopes all blocks to that ID.",
@@ -1617,7 +1693,7 @@ function registerDescribeTools(server) {
1617
1693
  filterableColumns: "string[] | null — which columns users can filter on (for data-table blocks)",
1618
1694
  },
1619
1695
  data_source_shape: {
1620
- type: "'structure' | 'query' — 'structure' fetches collection records directly, 'query' executes a smart query with {{variable}} substitution",
1696
+ type: "'structure' | 'query' — 'structure' fetches collection records directly, 'query' executes a smart query with '${variable}' substitution",
1621
1697
  ref: "string — the collection ID (for structure) or smart query ID (for query) — UUID",
1622
1698
  mode: "'list' | 'single' | 'aggregate' — optional. 'single' fetches one record (detail pages), 'list' fetches paginated records, 'aggregate' computes metrics",
1623
1699
  recordSlug: "string | undefined — the collection's record slug for direct resolution (avoids an extra lookup). Recommended for structure type.",
@@ -1638,7 +1714,7 @@ function registerDescribeTools(server) {
1638
1714
  static: "{ source: 'static', value: 'literalValue' } — a hardcoded default",
1639
1715
  },
1640
1716
  behavior: {
1641
- query_type: "For type:'query' — resolved variables substitute into smart query {{placeholders}} before execution",
1717
+ query_type: "For type:'query' — resolved variables substitute into smart query '${placeholders}' before execution",
1642
1718
  structure_type: "For type:'structure' — resolved variables become equality filters on the collection. IMPORTANT: the variable name 'id' is special — it matches the system record UUID (used for single-record fetch by ID). ALL OTHER variable names filter against user data fields and are automatically prefixed with 'data.' (e.g., variable 'requestId' filters on data.requestId in the JSONB column). System columns like createdAt, updatedAt, status do NOT get the data. prefix.",
1643
1719
  unresolved: "If any variable cannot be resolved, the block returns an empty result with a variableError message — NEVER unfiltered data",
1644
1720
  detail_page_pattern: "For a detail page: use variables: { id: { source: 'url', param: 'id' } } on the primary record-card block to fetch by system ID. For related-list blocks, use the actual data field name (e.g., variables: { requestId: { source: 'url', param: 'id' } }) — this filters related records where data.requestId matches the URL param. The 'id' variable fetches a record BY its UUID; other variable names filter records WHERE a field equals the value.",
@@ -29,10 +29,29 @@ declare function translateQueryRecordsArgs(args: QueryRecordsArgs): {
29
29
  resource: string;
30
30
  definition: Record<string, unknown>;
31
31
  };
32
+ /**
33
+ * Client-side `${var}` substitution for ad-hoc `query_records`. MCP agents
34
+ * often template queries (e.g. `${userId}` from prior tool output) — this
35
+ * helper resolves those placeholders before sending the body to the
36
+ * canonical `/records/query` endpoint, which has no native variable
37
+ * substitution.
38
+ *
39
+ * Behavior:
40
+ * - Replaces every `${name}` occurrence inside string values.
41
+ * - When the entire string is a single placeholder (e.g. `"${ids}"`), the
42
+ * value is replaced with the typed value as-is so arrays / numbers /
43
+ * booleans round-trip without being stringified.
44
+ * - Throws `Error("missing_required_variable: <name>")` when a placeholder
45
+ * references a variable that wasn't supplied — the caller surfaces the
46
+ * same code the executor uses for typed saved queries (contract §10).
47
+ * - Walks objects + arrays recursively; leaves non-strings untouched.
48
+ */
49
+ declare function substituteCanonicalVariables(node: unknown, values: Record<string, unknown>): unknown;
32
50
  export declare const _internal: {
33
51
  translateQueryRecordsArgs: typeof translateQueryRecordsArgs;
34
52
  parseLegacyFilters: typeof parseLegacyFilters;
35
53
  parseLegacySort: typeof parseLegacySort;
54
+ substituteCanonicalVariables: typeof substituteCanonicalVariables;
36
55
  };
37
56
  export declare function registerRecordTools(server: McpServer, sdk: CentraliSDK, centraliUrl: string, workspaceId: string): void;
38
57
  export {};
@@ -8,6 +8,17 @@ 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 __rest = (this && this.__rest) || function (s, e) {
12
+ var t = {};
13
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
14
+ t[p] = s[p];
15
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
16
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
17
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
18
+ t[p[i]] = s[p[i]];
19
+ }
20
+ return t;
21
+ };
11
22
  var __importDefault = (this && this.__importDefault) || function (mod) {
12
23
  return (mod && mod.__esModule) ? mod : { "default": mod };
13
24
  };
@@ -167,8 +178,63 @@ function translateQueryRecordsArgs(args) {
167
178
  // pagination on POST /records/query. `includeDeleted` is rejected above.
168
179
  return { resource, definition };
169
180
  }
181
+ /**
182
+ * Client-side `${var}` substitution for ad-hoc `query_records`. MCP agents
183
+ * often template queries (e.g. `${userId}` from prior tool output) — this
184
+ * helper resolves those placeholders before sending the body to the
185
+ * canonical `/records/query` endpoint, which has no native variable
186
+ * substitution.
187
+ *
188
+ * Behavior:
189
+ * - Replaces every `${name}` occurrence inside string values.
190
+ * - When the entire string is a single placeholder (e.g. `"${ids}"`), the
191
+ * value is replaced with the typed value as-is so arrays / numbers /
192
+ * booleans round-trip without being stringified.
193
+ * - Throws `Error("missing_required_variable: <name>")` when a placeholder
194
+ * references a variable that wasn't supplied — the caller surfaces the
195
+ * same code the executor uses for typed saved queries (contract §10).
196
+ * - Walks objects + arrays recursively; leaves non-strings untouched.
197
+ */
198
+ function substituteCanonicalVariables(node, values) {
199
+ if (node == null)
200
+ return node;
201
+ if (typeof node === "string") {
202
+ const wholeRe = /^\s*\$\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\s*$/;
203
+ const wholeMatch = node.match(wholeRe);
204
+ if (wholeMatch) {
205
+ const name = wholeMatch[1];
206
+ if (!(name in values)) {
207
+ throw new Error(`missing_required_variable: ${name}`);
208
+ }
209
+ return values[name];
210
+ }
211
+ return node.replace(/\$\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}/g, (_match, name) => {
212
+ if (!(name in values)) {
213
+ throw new Error(`missing_required_variable: ${name}`);
214
+ }
215
+ const v = values[name];
216
+ return v == null ? "" : String(v);
217
+ });
218
+ }
219
+ if (Array.isArray(node)) {
220
+ return node.map((item) => substituteCanonicalVariables(item, values));
221
+ }
222
+ if (typeof node === "object") {
223
+ const out = {};
224
+ for (const [k, v] of Object.entries(node)) {
225
+ out[k] = substituteCanonicalVariables(v, values);
226
+ }
227
+ return out;
228
+ }
229
+ return node;
230
+ }
170
231
  // Exposed for tests
171
- exports._internal = { translateQueryRecordsArgs, parseLegacyFilters, parseLegacySort };
232
+ exports._internal = {
233
+ translateQueryRecordsArgs,
234
+ parseLegacyFilters,
235
+ parseLegacySort,
236
+ substituteCanonicalVariables,
237
+ };
172
238
  /**
173
239
  * Ensures the SDK has a valid token.
174
240
  */
@@ -239,6 +305,8 @@ Example body:
239
305
  "select": { "fields": ["id", "data.status", "data.amount"] }
240
306
  }
241
307
 
308
+ Templating: any string operator value may reference '\${variableName}' placeholders; pass concrete values via the 'variables' arg and the placeholders are substituted before the query hits the engine. When a string is exactly one placeholder ('\${ids}'), the value passes through untyped (so arrays / numbers / booleans round-trip without stringification).
309
+
242
310
  Returns the canonical { data, meta } envelope. 'select', 'text', and 'include' are all accepted by the engine.`, {
243
311
  // Canonical fields (Phase 1)
244
312
  resource: zod_1.z
@@ -272,9 +340,18 @@ Returns the canonical { data, meta } envelope. 'select', 'text', and 'include' a
272
340
  .optional()
273
341
  .describe("Field projection. Example: { fields: ['id', 'data.status'] }."),
274
342
  include: zod_1.z
275
- .array(zod_1.z.object({ relation: zod_1.z.string() }))
343
+ .array(zod_1.z
344
+ .object({
345
+ relation: zod_1.z.string(),
346
+ where: zod_1.z.any().optional(),
347
+ select: zod_1.z
348
+ .object({ fields: zod_1.z.array(zod_1.z.string()) })
349
+ .optional(),
350
+ include: zod_1.z.any().optional(),
351
+ })
352
+ .passthrough())
276
353
  .optional()
277
- .describe("Relation expansion. Each entry names a relation declared on the collection."),
354
+ .describe("Relation expansion. Each entry names a relation declared on the collection and may carry an optional `where` (filter on the included rows in the target's namespace), `select` (projection on the attached row), and recursive `include` children."),
278
355
  // Legacy 5.4.0 fields — accepted, translated to canonical, removed after 2026-10-28
279
356
  recordSlug: zod_1.z
280
357
  .string()
@@ -308,12 +385,21 @@ Returns the canonical { data, meta } envelope. 'select', 'text', and 'include' a
308
385
  .boolean()
309
386
  .optional()
310
387
  .describe("[Deprecated 2026-10-28] Ignored — `meta.total` is always returned for offset pagination."),
388
+ // Templating
389
+ variables: zod_1.z
390
+ .record(zod_1.z.string(), zod_1.z.any())
391
+ .optional()
392
+ .describe("Values bound to '\${varName}' placeholders inside string operator values. Substituted client-side before the query hits the engine. Whole-string placeholders ('\${ids}') round-trip the typed value without stringification."),
311
393
  }, (args) => __awaiter(this, void 0, void 0, function* () {
312
394
  let resource = "<unknown>";
313
395
  try {
314
- const translated = translateQueryRecordsArgs(args);
396
+ const _a = args, { variables } = _a, rest = __rest(_a, ["variables"]);
397
+ const translated = translateQueryRecordsArgs(rest);
315
398
  resource = translated.resource;
316
- const result = yield sdk.records.query(resource, translated.definition);
399
+ const finalDefinition = variables
400
+ ? substituteCanonicalVariables(translated.definition, variables)
401
+ : translated.definition;
402
+ const result = yield sdk.records.query(resource, finalDefinition);
317
403
  return {
318
404
  content: [
319
405
  { type: "text", text: JSON.stringify(result, null, 2) },
@@ -0,0 +1,14 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { CentraliSDK } from "@centrali-io/centrali-sdk";
3
+ /**
4
+ * Canonical placeholders use `${var}`. Any string value inside the query
5
+ * containing a placeholder produces a referenced-variable name. Used for
6
+ * best-effort client-side validation in execute_saved_query so the AI gets
7
+ * a tighter error before round-tripping to the server.
8
+ */
9
+ declare function extractCanonicalVariableNames(definition: unknown): Set<string>;
10
+ export declare function registerSavedQueryTools(server: McpServer, sdk: CentraliSDK): void;
11
+ export declare const _internal: {
12
+ extractCanonicalVariableNames: typeof extractCanonicalVariableNames;
13
+ };
14
+ export {};