@centrali-io/centrali-mcp 5.5.1 → 6.1.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 +104 -10
- package/dist/index.js +2 -2
- package/dist/tools/compute.js +1 -1
- package/dist/tools/describe.js +143 -40
- package/dist/tools/records.d.ts +19 -0
- package/dist/tools/records.js +106 -9
- package/dist/tools/saved-queries.d.ts +14 -0
- package/dist/tools/saved-queries.js +457 -0
- package/package.json +2 -2
- package/src/index.ts +2 -2
- package/src/tools/compute.ts +1 -1
- package/src/tools/describe.ts +157 -41
- package/src/tools/records.ts +103 -8
- package/src/tools/saved-queries.ts +497 -0
- package/tests/records.translator.test.cjs +32 -7
- package/tests/savedQueriesRouting.test.cjs +148 -0
- package/tests/typedVariables.test.cjs +113 -0
- package/dist/tools/smart-queries.d.ts +0 -3
- package/dist/tools/smart-queries.js +0 -249
- package/src/tools/smart-queries.ts +0 -284
package/src/index.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { registerStructureTools, registerCollectionTools } from "./tools/structu
|
|
|
7
7
|
import { registerRecordTools } from "./tools/records.js";
|
|
8
8
|
import { registerSearchTools } from "./tools/search.js";
|
|
9
9
|
import { registerComputeTools } from "./tools/compute.js";
|
|
10
|
-
import {
|
|
10
|
+
import { registerSavedQueryTools } from "./tools/saved-queries.js";
|
|
11
11
|
import { registerOrchestrationTools } from "./tools/orchestrations.js";
|
|
12
12
|
import { registerInsightTools } from "./tools/insights.js";
|
|
13
13
|
import { registerValidationTools } from "./tools/validation.js";
|
|
@@ -70,7 +70,7 @@ async function main() {
|
|
|
70
70
|
registerRecordTools(server, sdk, baseUrl, workspaceId);
|
|
71
71
|
registerSearchTools(server, sdk);
|
|
72
72
|
registerComputeTools(server, sdk, baseUrl, workspaceId);
|
|
73
|
-
|
|
73
|
+
registerSavedQueryTools(server, sdk);
|
|
74
74
|
registerOrchestrationTools(server, sdk);
|
|
75
75
|
registerInsightTools(server, sdk);
|
|
76
76
|
registerValidationTools(server, sdk);
|
package/src/tools/compute.ts
CHANGED
|
@@ -422,7 +422,7 @@ export function registerComputeTools(server: McpServer, sdk: CentraliSDK, centra
|
|
|
422
422
|
triggerMetadata: z
|
|
423
423
|
.record(z.string(), z.any())
|
|
424
424
|
.optional()
|
|
425
|
-
.describe("Type-specific configuration. For event-driven: { eventType, recordSlug }. For scheduled: { scheduleType, cronExpression, timezone }. For http-trigger:
|
|
425
|
+
.describe("Type-specific configuration. For event-driven: { eventType, recordSlug }. For scheduled: { scheduleType, cronExpression, timezone }. For http-trigger: { path } where path is a URL-safe slug (e.g. 'stripe-webhook'); the trigger is reachable at `<api-host>/data/workspace/<workspaceSlug>/api/v1/http-trigger/<path>`. Optional native HMAC signature verification (no crypto in the function body): the **recommended path** is { validateSignature: true, provider: 'svix' | 'stripe' | 'slack' | 'github' | 'shopify', signingSecret } — the `provider` shortcut wires every wire-format detail (header names, signed-value format, digest encoding, secret encoding) from a built-in preset. Covers Svix (Clerk, Resend, Loops, OpenAI, Brex), Stripe, Slack, GitHub, Shopify out of the box. **Advanced**: any preset field can be overridden, or the preset can be skipped entirely by supplying the raw knobs directly — { validateSignature: true, signingSecret, signatureHeaderName, signatureIdHeaderName?, timestampHeaderName?, timestampExtractionPattern?, extractionPattern?, hmacAlgorithm?, hmacEncoding?, secretEncoding?, encoding?, payloadFormat? }. Defaults when no provider: hmacAlgorithm 'sha256', hmacEncoding 'base64', extractionPattern '^(?:v1,)?(.+)$', secretEncoding 'raw'; built-in 5-minute replay tolerance. Two-step setup recommended: (1) create the trigger with just { path } and optionally { provider } — the public URL is `<api-host>/data/workspace/<ws>/api/v1/http-trigger/<path>`; (2) register that URL with the webhook provider; (3) when the provider issues the signing secret, call update_trigger with validateSignature: true + signingSecret. Providers will not give a signing secret until they have the URL. For endpoint: { path, allowedMethods?, timeoutMs?, auth? } where path is URL-safe (e.g., 'create-order'), allowedMethods defaults to ['POST'], timeoutMs 1000-30000 (default 30000), auth is { mode: 'bearer'|'public'|'apiKey'|'hmac' | 'svix' | 'stripe' | 'slack' | 'github' | 'shopify', secret? } — provider-named modes are HMAC verification with the matching preset (auth.secret is the colocated signing secret; legacy `mode: 'hmac'` keeps its single-header body-only behaviour for back-compat). All trigger types accept params: a dictionary of static values passed to the function as triggerParams. Mark any secret with { value: 'plaintext', encrypt: true } to store it AES-256-GCM-encrypted at rest; it arrives as plaintext in triggerParams at execution time."),
|
|
426
426
|
enabled: z.boolean().optional().describe("Whether the trigger is enabled (default: true)"),
|
|
427
427
|
},
|
|
428
428
|
async ({ name, functionId, executionType, description, triggerMetadata, enabled }) => {
|
package/src/tools/describe.ts
CHANGED
|
@@ -91,16 +91,16 @@ export function registerDescribeTools(server: McpServer) {
|
|
|
91
91
|
},
|
|
92
92
|
smart_queries: {
|
|
93
93
|
summary:
|
|
94
|
-
"Reusable, parameterized queries defined in the console. Support {
|
|
95
|
-
describeWith: "
|
|
94
|
+
"Reusable, parameterized queries defined in the console. Support typed '${variable}' substitution and multi-collection joins (up to 4 joined collections per query).",
|
|
95
|
+
describeWith: "describe_saved_queries",
|
|
96
96
|
tools: [
|
|
97
|
-
"
|
|
98
|
-
"
|
|
99
|
-
"
|
|
100
|
-
"
|
|
101
|
-
"
|
|
102
|
-
"
|
|
103
|
-
"
|
|
97
|
+
"list_saved_queries",
|
|
98
|
+
"get_saved_query",
|
|
99
|
+
"create_saved_query",
|
|
100
|
+
"update_saved_query",
|
|
101
|
+
"delete_saved_query",
|
|
102
|
+
"execute_saved_query",
|
|
103
|
+
"test_saved_query",
|
|
104
104
|
],
|
|
105
105
|
},
|
|
106
106
|
orchestrations: {
|
|
@@ -917,6 +917,33 @@ export function registerDescribeTools(server: McpServer) {
|
|
|
917
917
|
triggerMetadata_examples: {
|
|
918
918
|
"event-driven": { eventType: "record_created", recordSlug: "orders" },
|
|
919
919
|
scheduled: { scheduleType: "cron", cronExpression: "0 9 * * *", timezone: "America/New_York" },
|
|
920
|
+
"http-trigger_url_shape": "Public URL: <api-host>/data/workspace/<workspaceSlug>/api/v1/http-trigger/<path>. The path is supplied by the caller in triggerMetadata.path and must be URL-safe. The URL is NOT returned in the create_trigger / get_trigger response — derive it from the workspaceSlug + path.",
|
|
921
|
+
"http-trigger_setup_flow": "Webhook signing secrets are chicken-and-egg: providers issue the secret only AFTER you give them a URL. Recommended flow — (1) create_trigger with { path, provider, validateSignature: true } and NO signingSecret — the trigger is created in a pending state with a stable URL; (2) register that URL in the provider's dashboard; (3) once the provider returns the signing secret, call update_trigger with signingSecret set. Until step 3 lands, the trigger rejects every request with 'HMAC signing secret not configured for this endpoint' — that's the desired safe default during URL-registration.",
|
|
922
|
+
"http-trigger_provider_shortcut": {
|
|
923
|
+
_note: "Recommended — one preset wires every wire-format detail. Use this for Svix (Clerk, Resend, Loops, OpenAI, Brex), Stripe, Slack, GitHub, Shopify. Override individual fields only if a provider's scheme has drifted (e.g. custom hmacAlgorithm).",
|
|
924
|
+
path: "clerk-webhook",
|
|
925
|
+
validateSignature: true,
|
|
926
|
+
provider: "svix",
|
|
927
|
+
signingSecret: "whsec_…",
|
|
928
|
+
},
|
|
929
|
+
"http-trigger_advanced_raw": {
|
|
930
|
+
_note: "Skip the preset for full manual control — useful for non-standard schemes the built-in providers don't cover. Every field maps 1:1 to the runtime SignatureMetadata interface.",
|
|
931
|
+
path: "custom-webhook",
|
|
932
|
+
validateSignature: true,
|
|
933
|
+
signingSecret: "whsec_…",
|
|
934
|
+
signatureHeaderName: "x-custom-signature",
|
|
935
|
+
timestampHeaderName: "x-custom-timestamp",
|
|
936
|
+
extractionPattern: "^v1,(.+)$",
|
|
937
|
+
hmacAlgorithm: "sha256",
|
|
938
|
+
hmacEncoding: "base64",
|
|
939
|
+
secretEncoding: "prefixed-base64",
|
|
940
|
+
},
|
|
941
|
+
endpoint_provider_shortcut: {
|
|
942
|
+
_note: "Provider-named endpoint auth mode + colocated secret. Equivalent to mode: 'hmac' + triggerMetadata.provider — the service layer normalises auth.secret onto triggerMetadata.signingSecret at create/update time.",
|
|
943
|
+
path: "stripe-events",
|
|
944
|
+
allowedMethods: ["POST"],
|
|
945
|
+
auth: { mode: "stripe", secret: "whsec_…" },
|
|
946
|
+
},
|
|
920
947
|
endpoint: { path: "create-order", allowedMethods: ["POST"], timeoutMs: 10000, auth: { mode: "bearer" } },
|
|
921
948
|
},
|
|
922
949
|
},
|
|
@@ -1061,11 +1088,11 @@ export function registerDescribeTools(server: McpServer) {
|
|
|
1061
1088
|
})
|
|
1062
1089
|
);
|
|
1063
1090
|
|
|
1064
|
-
// ──
|
|
1091
|
+
// ── Saved Queries ──────────────────────────────────────────────────
|
|
1065
1092
|
|
|
1066
1093
|
registerTool<any>(server,
|
|
1067
|
-
"
|
|
1068
|
-
"Get the schema reference for Centrali saved
|
|
1094
|
+
"describe_saved_queries",
|
|
1095
|
+
"Get the schema reference for Centrali saved queries. Explains the canonical QueryDefinition body, variable substitution, and execution.",
|
|
1069
1096
|
{},
|
|
1070
1097
|
async () => ({
|
|
1071
1098
|
content: [
|
|
@@ -1073,27 +1100,91 @@ export function registerDescribeTools(server: McpServer) {
|
|
|
1073
1100
|
type: "text",
|
|
1074
1101
|
text: JSON.stringify(
|
|
1075
1102
|
{
|
|
1076
|
-
domain: "Saved Queries
|
|
1103
|
+
domain: "Saved Queries",
|
|
1077
1104
|
description:
|
|
1078
|
-
"Saved queries are reusable, parameterized queries defined in the Centrali console. They store a canonical QueryDefinition plus optional variable declarations and
|
|
1105
|
+
"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.",
|
|
1079
1106
|
saved_query_shape: {
|
|
1080
1107
|
id: "UUID",
|
|
1081
1108
|
name: "string — display name",
|
|
1082
1109
|
recordSlug: "string — the collection this query targets",
|
|
1083
1110
|
description: "string | null",
|
|
1084
1111
|
queryDefinition:
|
|
1085
|
-
"object — canonical QueryDefinition (without 'resource' — filled in from recordSlug). Variables are referenced as '{
|
|
1112
|
+
"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.",
|
|
1113
|
+
variables:
|
|
1114
|
+
"Record<string, QueryVariableDefinition> | null — typed parameter declarations. Present on Phase 4 saved queries; null/absent on legacy untyped rows. See 'variable_declarations' below.",
|
|
1086
1115
|
},
|
|
1087
1116
|
query_definition_reference:
|
|
1088
|
-
"Use describe_records for the full QueryDefinition shape, operator vocabulary, and examples.",
|
|
1117
|
+
"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'.",
|
|
1118
|
+
joins: {
|
|
1119
|
+
description:
|
|
1120
|
+
"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'.",
|
|
1121
|
+
cap: 4,
|
|
1122
|
+
join_clause_shape: {
|
|
1123
|
+
foreignSlug:
|
|
1124
|
+
"string — the joined collection's record slug. LITERAL ONLY: '${var}' placeholders rejected (identifiers must be literal under SECURITY DEFINER auth).",
|
|
1125
|
+
localField:
|
|
1126
|
+
"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.",
|
|
1127
|
+
foreignField:
|
|
1128
|
+
"string — field on the joined collection (same dotted-vs-bare rules as localField). LITERAL ONLY: no '${var}' placeholders.",
|
|
1129
|
+
joinType:
|
|
1130
|
+
"'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.",
|
|
1131
|
+
select:
|
|
1132
|
+
"string[] (optional) — projection on the joined row; defaults to all readable fields",
|
|
1133
|
+
alias:
|
|
1134
|
+
"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.",
|
|
1135
|
+
},
|
|
1136
|
+
authorization:
|
|
1137
|
+
"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.",
|
|
1138
|
+
error_codes: [
|
|
1139
|
+
"joins_length_exceeded — more than 4 join entries",
|
|
1140
|
+
"duplicate_join_alias — same alias appears twice, OR alias equals primary 'resource', OR alias equals reserved '_joined'",
|
|
1141
|
+
"unsupported_combination — 'text' + 'joins', or 'include' + 'joins'",
|
|
1142
|
+
"unsupported_clause — joinType is 'right' or 'full' and 'DATA_QUERY_OUTER_JOINS_ENABLED' is off",
|
|
1143
|
+
"invalid_query — '${var}' placeholder in any identifier slot (foreignSlug / localField / foreignField / alias / joinType)",
|
|
1144
|
+
],
|
|
1145
|
+
response_shape:
|
|
1146
|
+
"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'.",
|
|
1147
|
+
example: {
|
|
1148
|
+
resource: "orders",
|
|
1149
|
+
where: { "data.status": { eq: "open" } },
|
|
1150
|
+
joins: [
|
|
1151
|
+
{
|
|
1152
|
+
foreignSlug: "customers",
|
|
1153
|
+
localField: "data.customerId",
|
|
1154
|
+
foreignField: "id",
|
|
1155
|
+
joinType: "left",
|
|
1156
|
+
select: ["id", "data.name", "data.email"],
|
|
1157
|
+
alias: "customer",
|
|
1158
|
+
},
|
|
1159
|
+
{
|
|
1160
|
+
foreignSlug: "regions",
|
|
1161
|
+
localField: "customer.data.regionId",
|
|
1162
|
+
foreignField: "id",
|
|
1163
|
+
joinType: "inner",
|
|
1164
|
+
alias: "region",
|
|
1165
|
+
},
|
|
1166
|
+
],
|
|
1167
|
+
sort: [{ field: "createdAt", direction: "desc" }],
|
|
1168
|
+
page: { limit: 50 },
|
|
1169
|
+
},
|
|
1170
|
+
example_response_row: {
|
|
1171
|
+
id: "order-123",
|
|
1172
|
+
data: { status: "open", amount: 99.99 },
|
|
1173
|
+
createdAt: "2026-04-01T00:00:00Z",
|
|
1174
|
+
_joined: {
|
|
1175
|
+
customer: { id: "cust-1", data: { name: "Acme", email: "a@b.co" } },
|
|
1176
|
+
region: { id: "reg-1", data: { code: "us-east" } },
|
|
1177
|
+
},
|
|
1178
|
+
},
|
|
1179
|
+
},
|
|
1089
1180
|
variable_syntax: {
|
|
1090
1181
|
description:
|
|
1091
|
-
"
|
|
1182
|
+
"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}'.",
|
|
1092
1183
|
example_query_definition: {
|
|
1093
1184
|
where: {
|
|
1094
1185
|
and: [
|
|
1095
|
-
{ "data.status": { eq: "{
|
|
1096
|
-
{ "data.amount": { gte: "{
|
|
1186
|
+
{ "data.status": { eq: "${statusFilter}" } },
|
|
1187
|
+
{ "data.amount": { gte: "${minAmount}" } },
|
|
1097
1188
|
],
|
|
1098
1189
|
},
|
|
1099
1190
|
sort: [{ field: "createdAt", direction: "desc" }],
|
|
@@ -1103,45 +1194,70 @@ export function registerDescribeTools(server: McpServer) {
|
|
|
1103
1194
|
statusFilter: "active",
|
|
1104
1195
|
minAmount: 1000,
|
|
1105
1196
|
},
|
|
1106
|
-
note: "Pass values via the 'variables' arg on
|
|
1197
|
+
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.",
|
|
1198
|
+
},
|
|
1199
|
+
variable_declarations: {
|
|
1200
|
+
description:
|
|
1201
|
+
"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).",
|
|
1202
|
+
shape: {
|
|
1203
|
+
type:
|
|
1204
|
+
"VariableType — 'string' | 'number' | 'boolean' | 'datetime' | 'id' | { array: VariableType } | { reference: '<collectionSlug>' }",
|
|
1205
|
+
required: "boolean (optional) — when true, the value must be supplied at execute time (or a 'default' must be set)",
|
|
1206
|
+
default: "scalar (optional) — used when the caller omits the value",
|
|
1207
|
+
description: "string (optional) — surfaced in tool descriptions and AI prompts",
|
|
1208
|
+
},
|
|
1209
|
+
example: {
|
|
1210
|
+
statusFilter: { type: "string", required: true, description: "Order status to filter by" },
|
|
1211
|
+
minAmount: { type: "number", default: 0 },
|
|
1212
|
+
since: { type: "datetime", required: true },
|
|
1213
|
+
customerIds: { type: { array: "id" } },
|
|
1214
|
+
ownerRef: { type: { reference: "users" } },
|
|
1215
|
+
},
|
|
1216
|
+
clearing_declarations:
|
|
1217
|
+
"Pass 'variables: null' on update_saved_query to clear declarations and revert the query to untyped string substitution.",
|
|
1107
1218
|
},
|
|
1108
1219
|
crud_tools: {
|
|
1109
|
-
|
|
1110
|
-
description: "Get a saved query by ID, including its full canonical definition",
|
|
1220
|
+
get_saved_query: {
|
|
1221
|
+
description: "Get a saved query by ID, including its full canonical definition and typed 'variables' declarations.",
|
|
1111
1222
|
required_params: ["recordSlug", "queryId"],
|
|
1112
1223
|
},
|
|
1113
|
-
|
|
1114
|
-
description: "Create a new saved query for a collection",
|
|
1224
|
+
create_saved_query: {
|
|
1225
|
+
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.",
|
|
1115
1226
|
required_params: ["recordSlug", "name", "queryDefinition"],
|
|
1116
|
-
optional_params: ["description"],
|
|
1227
|
+
optional_params: ["description", "variables"],
|
|
1117
1228
|
queryDefinition_example: {
|
|
1118
|
-
where: { "data.status": { eq: "
|
|
1229
|
+
where: { "data.status": { eq: "${statusFilter}" } },
|
|
1119
1230
|
sort: [{ field: "createdAt", direction: "desc" }],
|
|
1120
1231
|
page: { limit: 100 },
|
|
1121
1232
|
},
|
|
1233
|
+
variables_example: {
|
|
1234
|
+
statusFilter: { type: "string", required: true },
|
|
1235
|
+
},
|
|
1122
1236
|
},
|
|
1123
|
-
|
|
1124
|
-
description: "Update an existing saved query. Partial updates supported.",
|
|
1237
|
+
update_saved_query: {
|
|
1238
|
+
description: "Update an existing saved query. Partial updates supported. Pass 'variables: null' to clear typed declarations.",
|
|
1125
1239
|
required_params: ["recordSlug", "queryId"],
|
|
1126
|
-
optional_params: ["name", "description", "queryDefinition"],
|
|
1240
|
+
optional_params: ["name", "description", "queryDefinition", "variables"],
|
|
1127
1241
|
},
|
|
1128
|
-
|
|
1242
|
+
delete_saved_query: {
|
|
1129
1243
|
description: "Delete a saved query by ID",
|
|
1130
1244
|
required_params: ["recordSlug", "queryId"],
|
|
1131
1245
|
},
|
|
1132
|
-
|
|
1133
|
-
description: "Test execute a canonical query definition without saving it.
|
|
1246
|
+
test_saved_query: {
|
|
1247
|
+
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.",
|
|
1134
1248
|
required_params: ["recordSlug", "queryDefinition"],
|
|
1135
1249
|
optional_params: ["variables"],
|
|
1136
1250
|
},
|
|
1137
1251
|
},
|
|
1138
1252
|
tips: [
|
|
1139
|
-
"Use
|
|
1140
|
-
"
|
|
1141
|
-
"
|
|
1253
|
+
"Use list_saved_queries to discover available queries (optionally filter by collection slug); each row's 'variables' field tells you what to pass",
|
|
1254
|
+
"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",
|
|
1255
|
+
"Read the saved query's 'variables' map for type, required-ness, default, and description before calling execute_saved_query",
|
|
1142
1256
|
"Prefer saved queries over ad-hoc query_records filters for reusable logic",
|
|
1143
|
-
"
|
|
1144
|
-
"DO NOT author new saved queries with legacy '$'-prefixed operators ('$eq', '$gte', …)
|
|
1257
|
+
"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",
|
|
1258
|
+
"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.",
|
|
1259
|
+
"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.",
|
|
1260
|
+
"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.",
|
|
1145
1261
|
],
|
|
1146
1262
|
},
|
|
1147
1263
|
null,
|
|
@@ -1714,12 +1830,12 @@ export function registerDescribeTools(server: McpServer) {
|
|
|
1714
1830
|
"Set access policy with set_page_access_policy before publishing if you need auth",
|
|
1715
1831
|
"Use variable bindings on data sources to scope data dynamically — bind to URL params, auth context, record context, or static defaults",
|
|
1716
1832
|
"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' })",
|
|
1717
|
-
"For user-scoped views (e.g., My Approvals): bind a variable to { source: 'auth', field: 'userId' } and use a smart query with {
|
|
1833
|
+
"For user-scoped views (e.g., My Approvals): bind a variable to { source: 'auth', field: 'userId' } and use a smart query with '${assigneeId}'",
|
|
1718
1834
|
],
|
|
1719
1835
|
multi_page_pattern: {
|
|
1720
1836
|
description: "How to build a list→detail app with scoped related data",
|
|
1721
1837
|
steps: [
|
|
1722
|
-
"1. Create a smart query with {
|
|
1838
|
+
"1. Create a smart query with '${variables}' for the detail page's related data (e.g., filter by '${requestId}')",
|
|
1723
1839
|
"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'] }",
|
|
1724
1840
|
"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' } }",
|
|
1725
1841
|
"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.",
|
|
@@ -1816,7 +1932,7 @@ export function registerDescribeTools(server: McpServer) {
|
|
|
1816
1932
|
"string[] | null — which columns users can filter on (for data-table blocks)",
|
|
1817
1933
|
},
|
|
1818
1934
|
data_source_shape: {
|
|
1819
|
-
type: "'structure' | 'query' — 'structure' fetches collection records directly, 'query' executes a smart query with {
|
|
1935
|
+
type: "'structure' | 'query' — 'structure' fetches collection records directly, 'query' executes a smart query with '${variable}' substitution",
|
|
1820
1936
|
ref: "string — the collection ID (for structure) or smart query ID (for query) — UUID",
|
|
1821
1937
|
mode: "'list' | 'single' | 'aggregate' — optional. 'single' fetches one record (detail pages), 'list' fetches paginated records, 'aggregate' computes metrics",
|
|
1822
1938
|
recordSlug: "string | undefined — the collection's record slug for direct resolution (avoids an extra lookup). Recommended for structure type.",
|
|
@@ -1837,7 +1953,7 @@ export function registerDescribeTools(server: McpServer) {
|
|
|
1837
1953
|
static: "{ source: 'static', value: 'literalValue' } — a hardcoded default",
|
|
1838
1954
|
},
|
|
1839
1955
|
behavior: {
|
|
1840
|
-
query_type: "For type:'query' — resolved variables substitute into smart query {
|
|
1956
|
+
query_type: "For type:'query' — resolved variables substitute into smart query '${placeholders}' before execution",
|
|
1841
1957
|
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.",
|
|
1842
1958
|
unresolved: "If any variable cannot be resolved, the block returns an empty result with a variableError message — NEVER unfiltered data",
|
|
1843
1959
|
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.",
|
package/src/tools/records.ts
CHANGED
|
@@ -176,14 +176,82 @@ function translateQueryRecordsArgs(args: QueryRecordsArgs): {
|
|
|
176
176
|
if (relations.length > 0) definition.include = relations;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
//
|
|
180
|
-
//
|
|
179
|
+
// CEN-1259: count(*) is opt-in on POST /records/query. Default for the
|
|
180
|
+
// canonical surface is OFF (skip the duplicate predicate scan); legacy
|
|
181
|
+
// 5.4.0 callers expected `meta.total` always, so when the caller used a
|
|
182
|
+
// legacy field we preserve total-by-default. `includeTotal` (any surface)
|
|
183
|
+
// is the explicit override — including `includeTotal: false`, which must
|
|
184
|
+
// clear an existing canonical `page.withTotal: true` so the override is
|
|
185
|
+
// authoritative on both directions. `includeDeleted` is rejected above.
|
|
186
|
+
if (args.includeTotal !== undefined) {
|
|
187
|
+
definition.page = { ...(definition.page ?? { limit: 50 }), withTotal: args.includeTotal === true };
|
|
188
|
+
} else if (isLegacy) {
|
|
189
|
+
definition.page = { ...(definition.page ?? { limit: 50 }), withTotal: true };
|
|
190
|
+
}
|
|
181
191
|
|
|
182
192
|
return { resource, definition };
|
|
183
193
|
}
|
|
184
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Client-side `${var}` substitution for ad-hoc `query_records`. MCP agents
|
|
197
|
+
* often template queries (e.g. `${userId}` from prior tool output) — this
|
|
198
|
+
* helper resolves those placeholders before sending the body to the
|
|
199
|
+
* canonical `/records/query` endpoint, which has no native variable
|
|
200
|
+
* substitution.
|
|
201
|
+
*
|
|
202
|
+
* Behavior:
|
|
203
|
+
* - Replaces every `${name}` occurrence inside string values.
|
|
204
|
+
* - When the entire string is a single placeholder (e.g. `"${ids}"`), the
|
|
205
|
+
* value is replaced with the typed value as-is so arrays / numbers /
|
|
206
|
+
* booleans round-trip without being stringified.
|
|
207
|
+
* - Throws `Error("missing_required_variable: <name>")` when a placeholder
|
|
208
|
+
* references a variable that wasn't supplied — the caller surfaces the
|
|
209
|
+
* same code the executor uses for typed saved queries (contract §10).
|
|
210
|
+
* - Walks objects + arrays recursively; leaves non-strings untouched.
|
|
211
|
+
*/
|
|
212
|
+
function substituteCanonicalVariables(
|
|
213
|
+
node: unknown,
|
|
214
|
+
values: Record<string, unknown>
|
|
215
|
+
): unknown {
|
|
216
|
+
if (node == null) return node;
|
|
217
|
+
if (typeof node === "string") {
|
|
218
|
+
const wholeRe = /^\s*\$\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\s*$/;
|
|
219
|
+
const wholeMatch = node.match(wholeRe);
|
|
220
|
+
if (wholeMatch) {
|
|
221
|
+
const name = wholeMatch[1];
|
|
222
|
+
if (!(name in values)) {
|
|
223
|
+
throw new Error(`missing_required_variable: ${name}`);
|
|
224
|
+
}
|
|
225
|
+
return values[name];
|
|
226
|
+
}
|
|
227
|
+
return node.replace(/\$\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}/g, (_match, name: string) => {
|
|
228
|
+
if (!(name in values)) {
|
|
229
|
+
throw new Error(`missing_required_variable: ${name}`);
|
|
230
|
+
}
|
|
231
|
+
const v = values[name];
|
|
232
|
+
return v == null ? "" : String(v);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
if (Array.isArray(node)) {
|
|
236
|
+
return node.map((item) => substituteCanonicalVariables(item, values));
|
|
237
|
+
}
|
|
238
|
+
if (typeof node === "object") {
|
|
239
|
+
const out: Record<string, unknown> = {};
|
|
240
|
+
for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
|
|
241
|
+
out[k] = substituteCanonicalVariables(v, values);
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
return node;
|
|
246
|
+
}
|
|
247
|
+
|
|
185
248
|
// Exposed for tests
|
|
186
|
-
export const _internal = {
|
|
249
|
+
export const _internal = {
|
|
250
|
+
translateQueryRecordsArgs,
|
|
251
|
+
parseLegacyFilters,
|
|
252
|
+
parseLegacySort,
|
|
253
|
+
substituteCanonicalVariables,
|
|
254
|
+
};
|
|
187
255
|
/**
|
|
188
256
|
* Ensures the SDK has a valid token.
|
|
189
257
|
*/
|
|
@@ -261,6 +329,8 @@ Example body:
|
|
|
261
329
|
"select": { "fields": ["id", "data.status", "data.amount"] }
|
|
262
330
|
}
|
|
263
331
|
|
|
332
|
+
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).
|
|
333
|
+
|
|
264
334
|
Returns the canonical { data, meta } envelope. 'select', 'text', and 'include' are all accepted by the engine.`,
|
|
265
335
|
{
|
|
266
336
|
// Canonical fields (Phase 1)
|
|
@@ -301,9 +371,22 @@ Returns the canonical { data, meta } envelope. 'select', 'text', and 'include' a
|
|
|
301
371
|
.optional()
|
|
302
372
|
.describe("Field projection. Example: { fields: ['id', 'data.status'] }."),
|
|
303
373
|
include: z
|
|
304
|
-
.array(
|
|
374
|
+
.array(
|
|
375
|
+
z
|
|
376
|
+
.object({
|
|
377
|
+
relation: z.string(),
|
|
378
|
+
where: z.any().optional(),
|
|
379
|
+
select: z
|
|
380
|
+
.object({ fields: z.array(z.string()) })
|
|
381
|
+
.optional(),
|
|
382
|
+
include: z.any().optional(),
|
|
383
|
+
})
|
|
384
|
+
.passthrough(),
|
|
385
|
+
)
|
|
305
386
|
.optional()
|
|
306
|
-
.describe(
|
|
387
|
+
.describe(
|
|
388
|
+
"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."
|
|
389
|
+
),
|
|
307
390
|
|
|
308
391
|
// Legacy 5.4.0 fields — accepted, translated to canonical, removed after 2026-10-28
|
|
309
392
|
recordSlug: z
|
|
@@ -341,14 +424,26 @@ Returns the canonical { data, meta } envelope. 'select', 'text', and 'include' a
|
|
|
341
424
|
includeTotal: z
|
|
342
425
|
.boolean()
|
|
343
426
|
.optional()
|
|
344
|
-
.describe("
|
|
427
|
+
.describe("Set to true to request `meta.total` in the response. Default false — counting is skipped to keep list calls fast (CEN-1259)."),
|
|
428
|
+
|
|
429
|
+
// Templating
|
|
430
|
+
variables: z
|
|
431
|
+
.record(z.string(), z.any())
|
|
432
|
+
.optional()
|
|
433
|
+
.describe(
|
|
434
|
+
"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."
|
|
435
|
+
),
|
|
345
436
|
},
|
|
346
437
|
async (args) => {
|
|
347
438
|
let resource = "<unknown>";
|
|
348
439
|
try {
|
|
349
|
-
const
|
|
440
|
+
const { variables, ...rest } = args as QueryRecordsArgs & { variables?: Record<string, unknown> };
|
|
441
|
+
const translated = translateQueryRecordsArgs(rest as QueryRecordsArgs);
|
|
350
442
|
resource = translated.resource;
|
|
351
|
-
const
|
|
443
|
+
const finalDefinition = variables
|
|
444
|
+
? (substituteCanonicalVariables(translated.definition, variables) as Record<string, unknown>)
|
|
445
|
+
: translated.definition;
|
|
446
|
+
const result = await sdk.records.query(resource, finalDefinition as any);
|
|
352
447
|
return {
|
|
353
448
|
content: [
|
|
354
449
|
{ type: "text", text: JSON.stringify(result, null, 2) },
|