@centrali-io/centrali-mcp 5.5.0 → 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 +104 -10
- package/dist/index.js +2 -2
- package/dist/tools/describe.js +116 -40
- package/dist/tools/records.d.ts +19 -0
- package/dist/tools/records.js +91 -5
- 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/describe.ts +130 -41
- package/src/tools/records.ts +90 -5
- package/src/tools/saved-queries.ts +497 -0
- 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
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports._internal = void 0;
|
|
13
|
+
exports.registerSavedQueryTools = registerSavedQueryTools;
|
|
14
|
+
const zod_1 = require("zod");
|
|
15
|
+
const _register_js_1 = require("./_register.js");
|
|
16
|
+
// Tool names use the canonical `*_saved_query` form (CEN-1198 rename:
|
|
17
|
+
// "Smart Queries" → "Saved Queries"). Internally they route through
|
|
18
|
+
// `sdk.savedQueries.*` and accept canonical query bodies. The legacy
|
|
19
|
+
// `*_saved_query` MCP names were dropped before any external consumer
|
|
20
|
+
// shipped — there is no compatibility window.
|
|
21
|
+
const CANONICAL_QUERY_DEFINITION_HINT = `Canonical QueryDefinition body (saved queries store the inner shape — 'resource' is filled in from the collection slug arg).
|
|
22
|
+
|
|
23
|
+
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'.
|
|
24
|
+
|
|
25
|
+
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.
|
|
26
|
+
|
|
27
|
+
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:
|
|
28
|
+
{
|
|
29
|
+
"where": { "data.status": { "eq": "\${statusFilter}" } },
|
|
30
|
+
"sort": [{ "field": "createdAt", "direction": "desc" }],
|
|
31
|
+
"page": { "limit": 100 }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
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}'.
|
|
35
|
+
|
|
36
|
+
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.`;
|
|
37
|
+
const JOINS_HINT = `Optional 'joins[]' clause — saved queries may join up to 4 additional collections to the primary record set. Each entry has shape:
|
|
38
|
+
{
|
|
39
|
+
"foreignSlug": "<collection slug>", // required — literal, no \${var} placeholders
|
|
40
|
+
"localField": "data.customerId", // field on primary OR '<priorAlias>.<field>' to chain — literal
|
|
41
|
+
"foreignField": "id", // field on the joined table — literal
|
|
42
|
+
"joinType": "inner" | "left" | "right" | "full", // required — see availability note below
|
|
43
|
+
"select": ["data.name", "data.email"], // optional projection on the joined row
|
|
44
|
+
"alias": "customer" // optional; defaults to foreignSlug. REQUIRED when joining the same foreignSlug twice. Must not equal the primary 'resource' or "_joined". Literal.
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
Cap: 4 joins per query (JOINS_MAX_LENGTH).
|
|
48
|
+
|
|
49
|
+
Field reference rules:
|
|
50
|
+
- 'localField' / 'foreignField' use 'data.<field>' for JSONB-side properties; 'id', 'createdAt', and other top-level columns may be referenced bare.
|
|
51
|
+
- In a chained join, 'localField' may use '<alias>.<field>' to reference a prior join's row (e.g. 'customer.data.regionId').
|
|
52
|
+
|
|
53
|
+
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.
|
|
54
|
+
|
|
55
|
+
JoinType availability:
|
|
56
|
+
- 'inner' and 'left' are always available.
|
|
57
|
+
- '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.
|
|
58
|
+
|
|
59
|
+
Constraints:
|
|
60
|
+
- 'text' and 'joins' are mutually exclusive in the same query — combining them is rejected with 'unsupported_combination'.
|
|
61
|
+
- 'include' and 'joins' are mutually exclusive — combining them is rejected with 'unsupported_combination'.
|
|
62
|
+
- Aliases (or foreignSlugs when alias is omitted) must be unique within 'joins[]'; duplicates fail with 'duplicate_join_alias'.
|
|
63
|
+
- Aliases must not collide with the primary 'resource' or the reserved key "_joined" — also surfaced as 'duplicate_join_alias'.
|
|
64
|
+
- Exceeding 4 entries fails with 'joins_length_exceeded'.
|
|
65
|
+
|
|
66
|
+
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.
|
|
67
|
+
|
|
68
|
+
Response shape: each row's joined data lands under a top-level '_joined' key in the row, namespaced by alias:
|
|
69
|
+
{ "id": "...", "data": { ... }, "_joined": { "customer": { "id": "...", "data": { ... } } | null } }
|
|
70
|
+
|
|
71
|
+
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:
|
|
72
|
+
{ "id": null, "data": null, ..., "_joined": { "customer": { "id": "...", "data": { ... } } } }
|
|
73
|
+
|
|
74
|
+
This is symmetric SQL semantics — the row exists, one side is missing. Consumers detect phantom rows by 'id === null'.`;
|
|
75
|
+
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:
|
|
76
|
+
{ type, required?, default?, description? }
|
|
77
|
+
Where 'type' is one of:
|
|
78
|
+
- 'string' | 'number' | 'boolean' | 'datetime' | 'id'
|
|
79
|
+
- { array: <innerType> } (e.g. { array: 'string' })
|
|
80
|
+
- { reference: '<collectionSlug>' } (e.g. { reference: 'orders' })
|
|
81
|
+
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.`;
|
|
82
|
+
/**
|
|
83
|
+
* Canonical placeholders use `${var}`. Any string value inside the query
|
|
84
|
+
* containing a placeholder produces a referenced-variable name. Used for
|
|
85
|
+
* best-effort client-side validation in execute_saved_query so the AI gets
|
|
86
|
+
* a tighter error before round-tripping to the server.
|
|
87
|
+
*/
|
|
88
|
+
function extractCanonicalVariableNames(definition) {
|
|
89
|
+
const names = new Set();
|
|
90
|
+
const re = /\$\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}/g;
|
|
91
|
+
const visit = (node) => {
|
|
92
|
+
if (node == null)
|
|
93
|
+
return;
|
|
94
|
+
if (typeof node === "string") {
|
|
95
|
+
for (const match of node.matchAll(re))
|
|
96
|
+
names.add(match[1]);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (Array.isArray(node)) {
|
|
100
|
+
for (const item of node)
|
|
101
|
+
visit(item);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (typeof node === "object") {
|
|
105
|
+
for (const value of Object.values(node))
|
|
106
|
+
visit(value);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
visit(definition);
|
|
110
|
+
return names;
|
|
111
|
+
}
|
|
112
|
+
function registerSavedQueryTools(server, sdk) {
|
|
113
|
+
(0, _register_js_1.registerTool)(server, "list_saved_queries", "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.", {
|
|
114
|
+
recordSlug: zod_1.z
|
|
115
|
+
.string()
|
|
116
|
+
.optional()
|
|
117
|
+
.describe("Filter by collection record slug. If omitted, lists all saved queries in the workspace"),
|
|
118
|
+
}, (_a) => __awaiter(this, [_a], void 0, function* ({ recordSlug }) {
|
|
119
|
+
try {
|
|
120
|
+
const result = recordSlug
|
|
121
|
+
? yield sdk.savedQueries.list(recordSlug)
|
|
122
|
+
: yield sdk.savedQueries.listAll();
|
|
123
|
+
return {
|
|
124
|
+
content: [
|
|
125
|
+
{ type: "text", text: JSON.stringify(result.data, null, 2) },
|
|
126
|
+
],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
return {
|
|
131
|
+
content: [
|
|
132
|
+
{
|
|
133
|
+
type: "text",
|
|
134
|
+
text: (0, _register_js_1.formatError)(error, "listing saved queries"),
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
isError: true,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}));
|
|
141
|
+
(0, _register_js_1.registerTool)(server, "execute_saved_query", `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
|
+
recordSlug: zod_1.z
|
|
147
|
+
.string()
|
|
148
|
+
.describe("The collection's record slug the query belongs to"),
|
|
149
|
+
queryId: zod_1.z.string().describe("The saved query ID (UUID) to execute"),
|
|
150
|
+
variables: zod_1.z
|
|
151
|
+
.record(zod_1.z.string(), zod_1.z.any())
|
|
152
|
+
.optional()
|
|
153
|
+
.describe("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)."),
|
|
154
|
+
}, (_a) => __awaiter(this, [_a], void 0, function* ({ recordSlug, queryId, variables }) {
|
|
155
|
+
var _b, _c, _d, _e;
|
|
156
|
+
try {
|
|
157
|
+
// Best-effort client-side validation: fetch the saved query
|
|
158
|
+
// first so we can flag unknown / missing variables before the
|
|
159
|
+
// server round-trip. Server-side validation is still
|
|
160
|
+
// authoritative for type coercion and `variable_type_mismatch`.
|
|
161
|
+
let declared;
|
|
162
|
+
let referencedNames;
|
|
163
|
+
try {
|
|
164
|
+
const fetched = yield sdk.savedQueries.get(recordSlug, queryId);
|
|
165
|
+
const row = (_b = fetched.data) !== null && _b !== void 0 ? _b : {};
|
|
166
|
+
declared = ((_c = row.variables) !== null && _c !== void 0 ? _c : null);
|
|
167
|
+
referencedNames = extractCanonicalVariableNames((_e = (_d = row.queryDefinition) !== null && _d !== void 0 ? _d : row.query) !== null && _e !== void 0 ? _e : {});
|
|
168
|
+
}
|
|
169
|
+
catch (_f) {
|
|
170
|
+
// Permission/network blip — skip preflight and let the
|
|
171
|
+
// execute call surface the canonical error.
|
|
172
|
+
}
|
|
173
|
+
if (declared && typeof declared === "object") {
|
|
174
|
+
const declaredKeys = new Set(Object.keys(declared));
|
|
175
|
+
const provided = variables !== null && variables !== void 0 ? variables : {};
|
|
176
|
+
const extras = Object.keys(provided).filter((k) => !declaredKeys.has(k));
|
|
177
|
+
if (extras.length > 0) {
|
|
178
|
+
return {
|
|
179
|
+
content: [
|
|
180
|
+
{
|
|
181
|
+
type: "text",
|
|
182
|
+
text: `Error executing saved query '${queryId}': unknown variable(s) ` +
|
|
183
|
+
extras.map((e) => `'${e}'`).join(", ") +
|
|
184
|
+
`. Declared variables: ${Object.keys(declared).join(", ") || "(none)"}.`,
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
isError: true,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const missing = Object.entries(declared)
|
|
191
|
+
.filter(([name, def]) => (def === null || def === void 0 ? void 0 : def.required) === true && !(name in provided) && (def === null || def === void 0 ? void 0 : def.default) === undefined)
|
|
192
|
+
.map(([name]) => name);
|
|
193
|
+
if (missing.length > 0) {
|
|
194
|
+
return {
|
|
195
|
+
content: [
|
|
196
|
+
{
|
|
197
|
+
type: "text",
|
|
198
|
+
text: `Error executing saved query '${queryId}': missing required variable(s) ` +
|
|
199
|
+
missing.map((m) => `'${m}'`).join(", ") + ".",
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
isError: true,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
else if (referencedNames && referencedNames.size > 0) {
|
|
207
|
+
// Untyped (legacy) query that still references placeholders
|
|
208
|
+
// — flag if no values supplied at all, but don't gate on
|
|
209
|
+
// unknown keys because legacy substitution accepts extras.
|
|
210
|
+
const provided = variables !== null && variables !== void 0 ? variables : {};
|
|
211
|
+
const missing = Array.from(referencedNames).filter((n) => !(n in provided));
|
|
212
|
+
if (missing.length > 0) {
|
|
213
|
+
return {
|
|
214
|
+
content: [
|
|
215
|
+
{
|
|
216
|
+
type: "text",
|
|
217
|
+
text: `Error executing saved query '${queryId}': query references variable(s) ` +
|
|
218
|
+
missing.map((m) => `'${m}'`).join(", ") +
|
|
219
|
+
" but no values were provided. Pass them via the 'variables' arg.",
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
isError: true,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const options = variables ? { variables } : undefined;
|
|
227
|
+
const result = yield sdk.savedQueries.execute(recordSlug, queryId, options);
|
|
228
|
+
return {
|
|
229
|
+
content: [
|
|
230
|
+
{ type: "text", text: JSON.stringify(result.data, null, 2) },
|
|
231
|
+
],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
return {
|
|
236
|
+
content: [
|
|
237
|
+
{
|
|
238
|
+
type: "text",
|
|
239
|
+
text: (0, _register_js_1.formatError)(error, `executing saved query '${queryId}'`),
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
isError: true,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}));
|
|
246
|
+
(0, _register_js_1.registerTool)(server, "get_saved_query", "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.", {
|
|
247
|
+
recordSlug: zod_1.z.string().describe("The collection's record slug the query belongs to"),
|
|
248
|
+
queryId: zod_1.z.string().describe("The saved query ID (UUID)"),
|
|
249
|
+
}, (_a) => __awaiter(this, [_a], void 0, function* ({ recordSlug, queryId }) {
|
|
250
|
+
try {
|
|
251
|
+
const result = yield sdk.savedQueries.get(recordSlug, queryId);
|
|
252
|
+
return {
|
|
253
|
+
content: [
|
|
254
|
+
{ type: "text", text: JSON.stringify(result.data, null, 2) },
|
|
255
|
+
],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
return {
|
|
260
|
+
content: [
|
|
261
|
+
{
|
|
262
|
+
type: "text",
|
|
263
|
+
text: (0, _register_js_1.formatError)(error, `getting saved query '${queryId}'`),
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
isError: true,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}));
|
|
270
|
+
(0, _register_js_1.registerTool)(server, "create_saved_query", `Create a new saved query for a collection.
|
|
271
|
+
|
|
272
|
+
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.
|
|
273
|
+
|
|
274
|
+
${CANONICAL_QUERY_DEFINITION_HINT}
|
|
275
|
+
|
|
276
|
+
${JOINS_HINT}
|
|
277
|
+
|
|
278
|
+
${VARIABLE_DECLARATION_HINT}`, {
|
|
279
|
+
recordSlug: zod_1.z.string().describe("The collection's record slug to create the query for"),
|
|
280
|
+
name: zod_1.z.string().describe("Display name for the saved query"),
|
|
281
|
+
description: zod_1.z.string().optional().describe("Optional description"),
|
|
282
|
+
queryDefinition: zod_1.z
|
|
283
|
+
.record(zod_1.z.string(), zod_1.z.any())
|
|
284
|
+
.describe("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."),
|
|
285
|
+
variables: zod_1.z
|
|
286
|
+
.record(zod_1.z.string(), zod_1.z.any())
|
|
287
|
+
.optional()
|
|
288
|
+
.describe("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."),
|
|
289
|
+
}, (_a) => __awaiter(this, [_a], void 0, function* ({ recordSlug, name, description, queryDefinition, variables }) {
|
|
290
|
+
try {
|
|
291
|
+
const input = { name };
|
|
292
|
+
if (description !== undefined)
|
|
293
|
+
input.description = description;
|
|
294
|
+
if (variables !== undefined) {
|
|
295
|
+
// Typed declarations require the canonical create path,
|
|
296
|
+
// which expects a full QueryDefinition (including
|
|
297
|
+
// 'resource'). `recordSlug` is the URL-level source of
|
|
298
|
+
// truth for the target collection — apply it last so a
|
|
299
|
+
// caller-supplied `queryDefinition.resource` cannot
|
|
300
|
+
// silently retarget the create.
|
|
301
|
+
input.query = Object.assign(Object.assign({}, queryDefinition), { resource: recordSlug });
|
|
302
|
+
input.variables = variables;
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
// No typed declarations — keep the legacy slug-based path
|
|
306
|
+
// for back-compat with pre-Phase-4 callers.
|
|
307
|
+
input.queryDefinition = queryDefinition;
|
|
308
|
+
}
|
|
309
|
+
const result = yield sdk.savedQueries.create(recordSlug, input);
|
|
310
|
+
return {
|
|
311
|
+
content: [
|
|
312
|
+
{ type: "text", text: JSON.stringify(result.data, null, 2) },
|
|
313
|
+
],
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
return {
|
|
318
|
+
content: [
|
|
319
|
+
{
|
|
320
|
+
type: "text",
|
|
321
|
+
text: (0, _register_js_1.formatError)(error, `creating saved query '${name}' for '${recordSlug}'`),
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
isError: true,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
}));
|
|
328
|
+
(0, _register_js_1.registerTool)(server, "update_saved_query", `Update an existing saved query. Only include the fields you want to change.
|
|
329
|
+
|
|
330
|
+
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.
|
|
331
|
+
|
|
332
|
+
${CANONICAL_QUERY_DEFINITION_HINT}
|
|
333
|
+
|
|
334
|
+
${JOINS_HINT}
|
|
335
|
+
|
|
336
|
+
${VARIABLE_DECLARATION_HINT}`, {
|
|
337
|
+
recordSlug: zod_1.z.string().describe("The collection's record slug the query belongs to"),
|
|
338
|
+
queryId: zod_1.z.string().describe("The saved query ID (UUID) to update"),
|
|
339
|
+
name: zod_1.z.string().optional().describe("Updated display name"),
|
|
340
|
+
description: zod_1.z.string().optional().describe("Updated description"),
|
|
341
|
+
queryDefinition: zod_1.z
|
|
342
|
+
.record(zod_1.z.string(), zod_1.z.any())
|
|
343
|
+
.optional()
|
|
344
|
+
.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."),
|
|
345
|
+
variables: zod_1.z
|
|
346
|
+
.union([zod_1.z.record(zod_1.z.string(), zod_1.z.any()), zod_1.z.null()])
|
|
347
|
+
.optional()
|
|
348
|
+
.describe("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."),
|
|
349
|
+
}, (_a) => __awaiter(this, [_a], void 0, function* ({ recordSlug, queryId, name, description, queryDefinition, variables }) {
|
|
350
|
+
try {
|
|
351
|
+
const input = {};
|
|
352
|
+
if (name !== undefined)
|
|
353
|
+
input.name = name;
|
|
354
|
+
if (description !== undefined)
|
|
355
|
+
input.description = description;
|
|
356
|
+
if (variables !== undefined) {
|
|
357
|
+
// Canonical update: requires `query` (with resource) when
|
|
358
|
+
// queryDefinition is also being updated; otherwise just
|
|
359
|
+
// ship the variables change. Apply `resource` last so a
|
|
360
|
+
// caller-supplied `queryDefinition.resource` cannot trip
|
|
361
|
+
// the server's "resource cannot change on update" guard.
|
|
362
|
+
if (queryDefinition !== undefined) {
|
|
363
|
+
input.query = Object.assign(Object.assign({}, queryDefinition), { resource: recordSlug });
|
|
364
|
+
}
|
|
365
|
+
input.variables = variables;
|
|
366
|
+
}
|
|
367
|
+
else if (queryDefinition !== undefined) {
|
|
368
|
+
input.queryDefinition = queryDefinition;
|
|
369
|
+
}
|
|
370
|
+
const result = yield sdk.savedQueries.update(recordSlug, queryId, input);
|
|
371
|
+
return {
|
|
372
|
+
content: [
|
|
373
|
+
{ type: "text", text: JSON.stringify(result.data, null, 2) },
|
|
374
|
+
],
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
return {
|
|
379
|
+
content: [
|
|
380
|
+
{
|
|
381
|
+
type: "text",
|
|
382
|
+
text: (0, _register_js_1.formatError)(error, `updating saved query '${queryId}'`),
|
|
383
|
+
},
|
|
384
|
+
],
|
|
385
|
+
isError: true,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
}));
|
|
389
|
+
(0, _register_js_1.registerTool)(server, "delete_saved_query", "Delete a saved query by ID.", {
|
|
390
|
+
recordSlug: zod_1.z.string().describe("The collection's record slug the query belongs to"),
|
|
391
|
+
queryId: zod_1.z.string().describe("The saved query ID (UUID) to delete"),
|
|
392
|
+
}, (_a) => __awaiter(this, [_a], void 0, function* ({ recordSlug, queryId }) {
|
|
393
|
+
try {
|
|
394
|
+
yield sdk.savedQueries.delete(recordSlug, queryId);
|
|
395
|
+
return {
|
|
396
|
+
content: [
|
|
397
|
+
{
|
|
398
|
+
type: "text",
|
|
399
|
+
text: `Saved query '${queryId}' deleted from '${recordSlug}'.`,
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
return {
|
|
406
|
+
content: [
|
|
407
|
+
{
|
|
408
|
+
type: "text",
|
|
409
|
+
text: (0, _register_js_1.formatError)(error, `deleting saved query '${queryId}'`),
|
|
410
|
+
},
|
|
411
|
+
],
|
|
412
|
+
isError: true,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
}));
|
|
416
|
+
(0, _register_js_1.registerTool)(server, "test_saved_query", `Test execute a query definition without saving it. Useful for validating canonical syntax and previewing results before creating a saved query.
|
|
417
|
+
|
|
418
|
+
${CANONICAL_QUERY_DEFINITION_HINT}
|
|
419
|
+
|
|
420
|
+
${JOINS_HINT}
|
|
421
|
+
|
|
422
|
+
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.`, {
|
|
423
|
+
recordSlug: zod_1.z.string().describe("The collection's record slug to test against"),
|
|
424
|
+
queryDefinition: zod_1.z
|
|
425
|
+
.record(zod_1.z.string(), zod_1.z.any())
|
|
426
|
+
.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."),
|
|
427
|
+
variables: zod_1.z
|
|
428
|
+
.record(zod_1.z.string(), zod_1.z.any())
|
|
429
|
+
.optional()
|
|
430
|
+
.describe("Optional runtime values bound to '\${var}' placeholders. Substituted via legacy string substitution on the test path (every value is stringified)."),
|
|
431
|
+
}, (_a) => __awaiter(this, [_a], void 0, function* ({ recordSlug, queryDefinition, variables }) {
|
|
432
|
+
try {
|
|
433
|
+
const input = { queryDefinition };
|
|
434
|
+
if (variables !== undefined)
|
|
435
|
+
input.variables = variables;
|
|
436
|
+
const result = yield sdk.savedQueries.test(recordSlug, input);
|
|
437
|
+
return {
|
|
438
|
+
content: [
|
|
439
|
+
{ type: "text", text: JSON.stringify(result.data, null, 2) },
|
|
440
|
+
],
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
return {
|
|
445
|
+
content: [
|
|
446
|
+
{
|
|
447
|
+
type: "text",
|
|
448
|
+
text: (0, _register_js_1.formatError)(error, `test-executing saved query for '${recordSlug}'`),
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
isError: true,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
}));
|
|
455
|
+
}
|
|
456
|
+
// Exposed for tests
|
|
457
|
+
exports._internal = { extractCanonicalVariableNames };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@centrali-io/centrali-mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0",
|
|
4
4
|
"description": "Centrali MCP Server - AI assistant integration for Centrali workspaces",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"author": "Blueinit",
|
|
26
26
|
"license": "ISC",
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@centrali-io/centrali-sdk": "^
|
|
28
|
+
"@centrali-io/centrali-sdk": "^6.0.0",
|
|
29
29
|
"@modelcontextprotocol/sdk": "^1.28.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
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);
|