@centrali-io/centrali-mcp 4.4.7 → 4.4.8-rc.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/dist/index.js +1 -1
- package/dist/tools/structures.d.ts +1 -1
- package/dist/tools/structures.js +262 -23
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/tools/structures.ts +285 -21
package/dist/index.js
CHANGED
|
@@ -51,7 +51,7 @@ function main() {
|
|
|
51
51
|
version: "1.0.0",
|
|
52
52
|
});
|
|
53
53
|
// Register all tools
|
|
54
|
-
(0, structures_js_1.registerCollectionTools)(server, sdk);
|
|
54
|
+
(0, structures_js_1.registerCollectionTools)(server, sdk, baseUrl, workspaceId);
|
|
55
55
|
(0, structures_js_1.registerStructureTools)(server, sdk);
|
|
56
56
|
(0, records_js_1.registerRecordTools)(server, sdk);
|
|
57
57
|
(0, search_js_1.registerSearchTools)(server, sdk);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { CentraliSDK } from "@centrali-io/centrali-sdk";
|
|
3
3
|
export declare function registerStructureTools(server: McpServer, sdk: CentraliSDK): void;
|
|
4
|
-
export declare function registerCollectionTools(server: McpServer, sdk: CentraliSDK): void;
|
|
4
|
+
export declare function registerCollectionTools(server: McpServer, sdk: CentraliSDK, centraliUrl: string, workspaceId: string): void;
|
package/dist/tools/structures.js
CHANGED
|
@@ -8,18 +8,39 @@ 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 __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
11
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
15
|
exports.registerStructureTools = registerStructureTools;
|
|
13
16
|
exports.registerCollectionTools = registerCollectionTools;
|
|
17
|
+
const axios_1 = __importDefault(require("axios"));
|
|
14
18
|
const zod_1 = require("zod");
|
|
15
19
|
function formatError(error, context) {
|
|
16
|
-
var _a, _b;
|
|
20
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
|
|
17
21
|
if (error && typeof error === 'object') {
|
|
18
22
|
const e = error;
|
|
23
|
+
if ((_a = e.response) === null || _a === void 0 ? void 0 : _a.data) {
|
|
24
|
+
const d = e.response.data;
|
|
25
|
+
const code = (_f = (_e = (_c = (_b = d.code) !== null && _b !== void 0 ? _b : d.errorCode) !== null && _c !== void 0 ? _c : (_d = d.error) === null || _d === void 0 ? void 0 : _d.code) !== null && _e !== void 0 ? _e : e.response.status) !== null && _f !== void 0 ? _f : "ERROR";
|
|
26
|
+
const message = (_j = (_g = d.message) !== null && _g !== void 0 ? _g : (_h = d.error) === null || _h === void 0 ? void 0 : _h.message) !== null && _j !== void 0 ? _j : JSON.stringify(d);
|
|
27
|
+
let msg = `Error ${context}: [${code}] ${message}`;
|
|
28
|
+
// Include extra context from error response (e.g. classification details)
|
|
29
|
+
if (d.classification) {
|
|
30
|
+
msg += `\nClassification: ${JSON.stringify(d.classification)}`;
|
|
31
|
+
}
|
|
32
|
+
if (d.criticalChanges) {
|
|
33
|
+
msg += `\nCritical changes: ${JSON.stringify(d.criticalChanges)}`;
|
|
34
|
+
}
|
|
35
|
+
if (d.suggestion) {
|
|
36
|
+
msg += `\nSuggestion: ${d.suggestion}`;
|
|
37
|
+
}
|
|
38
|
+
return msg;
|
|
39
|
+
}
|
|
19
40
|
if ('message' in e) {
|
|
20
41
|
let msg = `Error ${context}`;
|
|
21
42
|
if ('code' in e || 'status' in e) {
|
|
22
|
-
msg += `: [${(
|
|
43
|
+
msg += `: [${(_l = (_k = e.code) !== null && _k !== void 0 ? _k : e.status) !== null && _l !== void 0 ? _l : 'ERROR'}] ${e.message}`;
|
|
23
44
|
}
|
|
24
45
|
else {
|
|
25
46
|
msg += `: ${e.message}`;
|
|
@@ -34,6 +55,58 @@ function formatError(error, context) {
|
|
|
34
55
|
}
|
|
35
56
|
return `Error ${context}: ${error instanceof Error ? error.message : String(error)}`;
|
|
36
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Ensures the SDK has a valid token.
|
|
60
|
+
*/
|
|
61
|
+
function ensureToken(sdk) {
|
|
62
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
63
|
+
let token = sdk.getToken();
|
|
64
|
+
if (token)
|
|
65
|
+
return token;
|
|
66
|
+
try {
|
|
67
|
+
yield sdk.functions.list({ limit: 1 });
|
|
68
|
+
}
|
|
69
|
+
catch ( /* token refresh side effect */_a) { /* token refresh side effect */ }
|
|
70
|
+
return sdk.getToken();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Creates an axios instance pointing at the data service collections API.
|
|
75
|
+
*/
|
|
76
|
+
function createDataClient(sdk, centraliUrl, workspaceId, pathSuffix) {
|
|
77
|
+
const url = new URL(centraliUrl);
|
|
78
|
+
const hostname = url.hostname.startsWith("api.")
|
|
79
|
+
? url.hostname
|
|
80
|
+
: `api.${url.hostname}`;
|
|
81
|
+
const baseURL = `${url.protocol}//${hostname}/data/workspace/${workspaceId}/api/v1/${pathSuffix}`;
|
|
82
|
+
const client = axios_1.default.create({ baseURL });
|
|
83
|
+
client.interceptors.request.use((config) => __awaiter(this, void 0, void 0, function* () {
|
|
84
|
+
const token = yield ensureToken(sdk);
|
|
85
|
+
if (token) {
|
|
86
|
+
config.headers.Authorization = `Bearer ${token}`;
|
|
87
|
+
}
|
|
88
|
+
return config;
|
|
89
|
+
}));
|
|
90
|
+
client.interceptors.response.use((response) => response, (error) => __awaiter(this, void 0, void 0, function* () {
|
|
91
|
+
var _a, _b;
|
|
92
|
+
const originalRequest = error.config;
|
|
93
|
+
const isAuthError = ((_a = error.response) === null || _a === void 0 ? void 0 : _a.status) === 401 || ((_b = error.response) === null || _b === void 0 ? void 0 : _b.status) === 403;
|
|
94
|
+
if (isAuthError && !originalRequest._hasRetried) {
|
|
95
|
+
originalRequest._hasRetried = true;
|
|
96
|
+
try {
|
|
97
|
+
yield sdk.functions.list({ limit: 1 });
|
|
98
|
+
}
|
|
99
|
+
catch ( /* token refresh side effect */_c) { /* token refresh side effect */ }
|
|
100
|
+
const token = sdk.getToken();
|
|
101
|
+
if (token) {
|
|
102
|
+
originalRequest.headers.Authorization = `Bearer ${token}`;
|
|
103
|
+
return client.request(originalRequest);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return Promise.reject(error);
|
|
107
|
+
}));
|
|
108
|
+
return client;
|
|
109
|
+
}
|
|
37
110
|
function registerStructureTools(server, sdk) {
|
|
38
111
|
server.tool("list_structures", "[DEPRECATED: use list_collections instead] List all data structures (schemas) in the Centrali workspace. Returns name, slug, description, and property definitions for each structure.", {
|
|
39
112
|
page: zod_1.z.number().optional().describe("Page number (1-indexed)"),
|
|
@@ -85,7 +158,8 @@ function registerStructureTools(server, sdk) {
|
|
|
85
158
|
}
|
|
86
159
|
}));
|
|
87
160
|
}
|
|
88
|
-
function registerCollectionTools(server, sdk) {
|
|
161
|
+
function registerCollectionTools(server, sdk, centraliUrl, workspaceId) {
|
|
162
|
+
const getCollectionsClient = () => createDataClient(sdk, centraliUrl, workspaceId, "collections");
|
|
89
163
|
server.tool("list_collections", "List all data collections (schemas) in the Centrali workspace. Returns name, slug, description, and property definitions for each collection.", {
|
|
90
164
|
page: zod_1.z.number().optional().describe("Page number (1-indexed)"),
|
|
91
165
|
limit: zod_1.z.number().optional().describe("Results per page"),
|
|
@@ -181,14 +255,132 @@ function registerCollectionTools(server, sdk) {
|
|
|
181
255
|
};
|
|
182
256
|
}
|
|
183
257
|
}));
|
|
184
|
-
|
|
258
|
+
// ── Schema Update Workflow ─────────────────────────────────────────
|
|
259
|
+
//
|
|
260
|
+
// Updating a collection schema is a multi-step process because changes
|
|
261
|
+
// may require migrating existing records. The workflow is:
|
|
262
|
+
//
|
|
263
|
+
// 1. analyze_collection_update — classify changes, get suggested fixes
|
|
264
|
+
// 2. preview_collection_migration — (optional) test fixes on sample records
|
|
265
|
+
// 3. update_collection — apply the update, with migration plan if needed
|
|
266
|
+
// 4. get_collection_upgrade_progress — poll async migration status
|
|
267
|
+
//
|
|
268
|
+
// For simple changes (adding optional fields, renaming, metadata-only),
|
|
269
|
+
// update_collection handles everything automatically. For breaking or
|
|
270
|
+
// critical changes, analyze first to understand what's needed.
|
|
271
|
+
server.tool("analyze_collection_update", `Analyze proposed schema changes BEFORE applying them. Returns whether migration is needed, what changes are breaking/critical, suggested fixes, and estimated duration.
|
|
272
|
+
|
|
273
|
+
WHEN TO USE: Call this before update_collection when you are changing properties (adding required fields, changing types, renaming fields, deleting fields, or toggling isSecret). This tells you exactly what will happen and what migration plan is needed.
|
|
274
|
+
|
|
275
|
+
CHANGE CATEGORIES:
|
|
276
|
+
- Non-breaking (no migration): adding optional fields, metadata changes → update_collection handles directly
|
|
277
|
+
- Breaking (migration, skippable on non-strict): field renames, deletions, type changes, constraint changes → can skip on schemaless/auto-evolving collections
|
|
278
|
+
- Critical (migration, NEVER skippable): adding required fields without defaults, toggling isSecret → must provide a migration plan with fixes
|
|
279
|
+
|
|
280
|
+
RESPONSE FIELDS:
|
|
281
|
+
- needsMigration: whether existing records need to be updated
|
|
282
|
+
- canSkip: whether migration can be skipped (only false for critical changes or strict schemas with breaking changes)
|
|
283
|
+
- recordCount: number of existing records affected
|
|
284
|
+
- classification: detailed breakdown of all changes by category
|
|
285
|
+
- suggestedFixes: auto-generated fixes you can use in update_collection's migrationPlan
|
|
286
|
+
- estimatedDuration: 'instant' | 'seconds' | 'minutes' | 'long'`, {
|
|
287
|
+
collectionId: zod_1.z.string().describe("The collection ID (UUID) to analyze"),
|
|
288
|
+
properties: zod_1.z
|
|
289
|
+
.array(zod_1.z.record(zod_1.z.string(), zod_1.z.any()))
|
|
290
|
+
.describe("The COMPLETE new properties array (all fields, not just changed ones). Must include existing property IDs for fields being kept/modified."),
|
|
291
|
+
}, (_a) => __awaiter(this, [_a], void 0, function* ({ collectionId, properties }) {
|
|
292
|
+
try {
|
|
293
|
+
const client = getCollectionsClient();
|
|
294
|
+
const result = yield client.post(`/${collectionId}/analyze-update`, { properties });
|
|
295
|
+
const data = result.data;
|
|
296
|
+
// Build a human-readable summary for the LLM
|
|
297
|
+
const lines = [];
|
|
298
|
+
lines.push(`## Analysis for collection ${collectionId}`);
|
|
299
|
+
lines.push(`- Records affected: ${data.recordCount}`);
|
|
300
|
+
lines.push(`- Needs migration: ${data.needsMigration}`);
|
|
301
|
+
lines.push(`- Can skip migration: ${data.canSkip}`);
|
|
302
|
+
lines.push(`- Estimated duration: ${data.estimatedDuration}`);
|
|
303
|
+
if (!data.needsMigration) {
|
|
304
|
+
lines.push(`\n✅ No migration needed. You can call update_collection directly.`);
|
|
305
|
+
}
|
|
306
|
+
else if (data.canSkip) {
|
|
307
|
+
lines.push(`\n⚠️ Migration can be skipped. Call update_collection without a migrationPlan to skip, or provide one for a clean migration.`);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
lines.push(`\n🔴 Migration REQUIRED and cannot be skipped. You must call update_collection with a migrationPlan.`);
|
|
311
|
+
lines.push(`Use mode 'auto' to apply suggested fixes, or mode 'manual' with custom fixes.`);
|
|
312
|
+
}
|
|
313
|
+
if (data.suggestedFixes && data.suggestedFixes.length > 0) {
|
|
314
|
+
lines.push(`\n### Suggested fixes (use these in migrationPlan.fixes or mode 'auto'):`);
|
|
315
|
+
}
|
|
316
|
+
lines.push(`\n### Full analysis:`);
|
|
317
|
+
lines.push(JSON.stringify(data, null, 2));
|
|
318
|
+
return {
|
|
319
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
return {
|
|
324
|
+
content: [{ type: "text", text: formatError(error, `analyzing update for collection '${collectionId}'`) }],
|
|
325
|
+
isError: true,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
}));
|
|
329
|
+
server.tool("preview_collection_migration", `Preview the effect of migration fixes on sample records BEFORE applying them. Shows before/after snapshots so you can verify fixes are correct.
|
|
330
|
+
|
|
331
|
+
WHEN TO USE: After analyze_collection_update returns suggestedFixes, call this to see how those fixes would transform actual records. Useful for validating type conversions, default values, and expressions before committing.`, {
|
|
332
|
+
collectionId: zod_1.z.string().describe("The collection ID (UUID)"),
|
|
333
|
+
fixes: zod_1.z
|
|
334
|
+
.array(zod_1.z.record(zod_1.z.string(), zod_1.z.any()))
|
|
335
|
+
.describe("Array of migration fixes to preview (from analyze_collection_update suggestedFixes or custom fixes)"),
|
|
336
|
+
properties: zod_1.z
|
|
337
|
+
.array(zod_1.z.record(zod_1.z.string(), zod_1.z.any()))
|
|
338
|
+
.describe("The COMPLETE new properties array (same as passed to analyze_collection_update)"),
|
|
339
|
+
}, (_a) => __awaiter(this, [_a], void 0, function* ({ collectionId, fixes, properties }) {
|
|
340
|
+
try {
|
|
341
|
+
const client = getCollectionsClient();
|
|
342
|
+
const result = yield client.post(`/${collectionId}/preview-fixes`, { fixes, properties });
|
|
343
|
+
return {
|
|
344
|
+
content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
return {
|
|
349
|
+
content: [{ type: "text", text: formatError(error, `previewing fixes for collection '${collectionId}'`) }],
|
|
350
|
+
isError: true,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
}));
|
|
354
|
+
server.tool("update_collection", `Update a collection's schema using the safe upgrade endpoint. This handles both simple updates and complex migrations.
|
|
355
|
+
|
|
356
|
+
HOW IT WORKS:
|
|
357
|
+
1. If your changes don't require migration (adding optional fields, metadata changes) → completes immediately
|
|
358
|
+
2. If migration is required but you don't provide a migrationPlan → returns an error telling you what's needed
|
|
359
|
+
3. If migration is required and you provide migrationPlan → starts the migration (may be async for large collections)
|
|
360
|
+
|
|
361
|
+
RECOMMENDED WORKFLOW:
|
|
362
|
+
1. Call analyze_collection_update first to understand what changes require
|
|
363
|
+
2. If needsMigration=false → call this tool without migrationPlan
|
|
364
|
+
3. If needsMigration=true and canSkip=true → call this tool without migrationPlan to skip migration
|
|
365
|
+
4. If needsMigration=true and canSkip=false → call this tool WITH migrationPlan
|
|
366
|
+
5. If status='in_progress' → poll with get_collection_upgrade_progress
|
|
367
|
+
|
|
368
|
+
MIGRATION PLAN:
|
|
369
|
+
- mode 'auto': Uses system-generated fixes from analyze_collection_update (handles renames, type conversions, deletions automatically)
|
|
370
|
+
- mode 'manual': Provide your own fixes array for full control over how records are transformed
|
|
371
|
+
|
|
372
|
+
FIX FORMAT (for manual mode):
|
|
373
|
+
Each fix is an object with: { fieldName, fixType, value?, expression?, applyToAll?, filterConditions? }
|
|
374
|
+
- fixType: 'static_value' (set a fixed value), 'expression' (JS expression with 'record' variable), 'auto' (system handles it)
|
|
375
|
+
- For new required fields: provide a default value or expression
|
|
376
|
+
- For type changes: provide a conversion expression`, {
|
|
185
377
|
collectionId: zod_1.z.string().describe("The collection ID (UUID) to update"),
|
|
186
378
|
name: zod_1.z.string().optional().describe("Updated display name"),
|
|
187
379
|
description: zod_1.z.string().optional().describe("Updated description"),
|
|
188
380
|
properties: zod_1.z
|
|
189
381
|
.array(zod_1.z.record(zod_1.z.string(), zod_1.z.any()))
|
|
190
382
|
.optional()
|
|
191
|
-
.describe("
|
|
383
|
+
.describe("The COMPLETE new properties array. Must include ALL fields (existing + new). Include property 'id' for existing fields being kept/modified."),
|
|
192
384
|
enableVersioning: zod_1.z.boolean().optional().describe("Enable or disable record versioning"),
|
|
193
385
|
tags: zod_1.z.array(zod_1.z.string()).optional().describe("Updated tags"),
|
|
194
386
|
defaultTtlSeconds: zod_1.z
|
|
@@ -196,36 +388,83 @@ function registerCollectionTools(server, sdk) {
|
|
|
196
388
|
.nullable()
|
|
197
389
|
.optional()
|
|
198
390
|
.describe("Default TTL in seconds for new records. Set to null to clear."),
|
|
199
|
-
|
|
391
|
+
migrationPlan: zod_1.z
|
|
392
|
+
.object({
|
|
393
|
+
mode: zod_1.z.enum(["auto", "manual"]).describe("'auto' uses suggested fixes, 'manual' uses your custom fixes array"),
|
|
394
|
+
fixes: zod_1.z
|
|
395
|
+
.array(zod_1.z.record(zod_1.z.string(), zod_1.z.any()))
|
|
396
|
+
.optional()
|
|
397
|
+
.describe("Required for 'manual' mode. Array of fix objects specifying how to transform records."),
|
|
398
|
+
})
|
|
399
|
+
.optional()
|
|
400
|
+
.describe("Migration plan for breaking/critical changes. Omit to skip migration (only works if canSkip=true from analyze). Required when canSkip=false."),
|
|
401
|
+
}, (_a) => __awaiter(this, [_a], void 0, function* ({ collectionId, name, description, properties, enableVersioning, tags, defaultTtlSeconds, migrationPlan }) {
|
|
200
402
|
try {
|
|
201
|
-
const
|
|
403
|
+
const client = getCollectionsClient();
|
|
404
|
+
const body = {};
|
|
202
405
|
if (name !== undefined)
|
|
203
|
-
|
|
406
|
+
body.name = name;
|
|
204
407
|
if (description !== undefined)
|
|
205
|
-
|
|
408
|
+
body.description = description;
|
|
206
409
|
if (properties !== undefined)
|
|
207
|
-
|
|
410
|
+
body.properties = properties;
|
|
208
411
|
if (enableVersioning !== undefined)
|
|
209
|
-
|
|
412
|
+
body.enableVersioning = enableVersioning;
|
|
210
413
|
if (tags !== undefined)
|
|
211
|
-
|
|
414
|
+
body.tags = tags;
|
|
212
415
|
if (defaultTtlSeconds !== undefined)
|
|
213
|
-
|
|
214
|
-
|
|
416
|
+
body.defaultTtlSeconds = defaultTtlSeconds;
|
|
417
|
+
if (migrationPlan !== undefined)
|
|
418
|
+
body.migrationPlan = migrationPlan;
|
|
419
|
+
const result = yield client.post(`/${collectionId}/upgrade`, body);
|
|
420
|
+
const data = result.data;
|
|
421
|
+
if (data.status === 'completed') {
|
|
422
|
+
return {
|
|
423
|
+
content: [{
|
|
424
|
+
type: "text",
|
|
425
|
+
text: `Collection updated successfully.\n\n${JSON.stringify(data.structure, null, 2)}`,
|
|
426
|
+
}],
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
if (data.status === 'in_progress') {
|
|
430
|
+
return {
|
|
431
|
+
content: [{
|
|
432
|
+
type: "text",
|
|
433
|
+
text: [
|
|
434
|
+
`Migration started (async). Records are being updated in the background.`,
|
|
435
|
+
`- Job ID: ${data.jobId}`,
|
|
436
|
+
`- Progress URL: ${data.progressUrl}`,
|
|
437
|
+
``,
|
|
438
|
+
`Call get_collection_upgrade_progress with collectionId='${collectionId}' and jobId='${data.jobId}' to check status.`,
|
|
439
|
+
].join("\n"),
|
|
440
|
+
}],
|
|
441
|
+
};
|
|
442
|
+
}
|
|
215
443
|
return {
|
|
216
|
-
content: [
|
|
217
|
-
{ type: "text", text: JSON.stringify(result.data, null, 2) },
|
|
218
|
-
],
|
|
444
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
219
445
|
};
|
|
220
446
|
}
|
|
221
447
|
catch (error) {
|
|
222
448
|
return {
|
|
223
|
-
content: [
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
449
|
+
content: [{ type: "text", text: formatError(error, `updating collection '${collectionId}'`) }],
|
|
450
|
+
isError: true,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
}));
|
|
454
|
+
server.tool("get_collection_upgrade_progress", "Check the progress of an async collection migration/upgrade job. Call this after update_collection returns status='in_progress'.", {
|
|
455
|
+
collectionId: zod_1.z.string().describe("The collection ID (UUID)"),
|
|
456
|
+
jobId: zod_1.z.string().describe("The job ID returned by update_collection"),
|
|
457
|
+
}, (_a) => __awaiter(this, [_a], void 0, function* ({ collectionId, jobId }) {
|
|
458
|
+
try {
|
|
459
|
+
const client = getCollectionsClient();
|
|
460
|
+
const result = yield client.get(`/${collectionId}/upgrade/${jobId}/progress`);
|
|
461
|
+
return {
|
|
462
|
+
content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
return {
|
|
467
|
+
content: [{ type: "text", text: formatError(error, `getting upgrade progress for '${collectionId}'`) }],
|
|
229
468
|
isError: true,
|
|
230
469
|
};
|
|
231
470
|
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -45,7 +45,7 @@ async function main() {
|
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
// Register all tools
|
|
48
|
-
registerCollectionTools(server, sdk);
|
|
48
|
+
registerCollectionTools(server, sdk, baseUrl, workspaceId);
|
|
49
49
|
registerStructureTools(server, sdk);
|
|
50
50
|
registerRecordTools(server, sdk);
|
|
51
51
|
registerSearchTools(server, sdk);
|
package/src/tools/structures.ts
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { CentraliSDK } from "@centrali-io/centrali-sdk";
|
|
3
|
+
import axios, { AxiosInstance } from "axios";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
|
|
5
6
|
function formatError(error: unknown, context: string): string {
|
|
6
7
|
if (error && typeof error === 'object') {
|
|
7
8
|
const e = error as Record<string, any>;
|
|
9
|
+
if (e.response?.data) {
|
|
10
|
+
const d = e.response.data;
|
|
11
|
+
const code = d.code ?? d.errorCode ?? d.error?.code ?? e.response.status ?? "ERROR";
|
|
12
|
+
const message = d.message ?? d.error?.message ?? JSON.stringify(d);
|
|
13
|
+
let msg = `Error ${context}: [${code}] ${message}`;
|
|
14
|
+
// Include extra context from error response (e.g. classification details)
|
|
15
|
+
if (d.classification) {
|
|
16
|
+
msg += `\nClassification: ${JSON.stringify(d.classification)}`;
|
|
17
|
+
}
|
|
18
|
+
if (d.criticalChanges) {
|
|
19
|
+
msg += `\nCritical changes: ${JSON.stringify(d.criticalChanges)}`;
|
|
20
|
+
}
|
|
21
|
+
if (d.suggestion) {
|
|
22
|
+
msg += `\nSuggestion: ${d.suggestion}`;
|
|
23
|
+
}
|
|
24
|
+
return msg;
|
|
25
|
+
}
|
|
8
26
|
if ('message' in e) {
|
|
9
27
|
let msg = `Error ${context}`;
|
|
10
28
|
if ('code' in e || 'status' in e) {
|
|
@@ -23,6 +41,63 @@ function formatError(error: unknown, context: string): string {
|
|
|
23
41
|
return `Error ${context}: ${error instanceof Error ? error.message : String(error)}`;
|
|
24
42
|
}
|
|
25
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Ensures the SDK has a valid token.
|
|
46
|
+
*/
|
|
47
|
+
async function ensureToken(sdk: CentraliSDK): Promise<string | null> {
|
|
48
|
+
let token = sdk.getToken();
|
|
49
|
+
if (token) return token;
|
|
50
|
+
try {
|
|
51
|
+
await sdk.functions.list({ limit: 1 });
|
|
52
|
+
} catch { /* token refresh side effect */ }
|
|
53
|
+
return sdk.getToken();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Creates an axios instance pointing at the data service collections API.
|
|
58
|
+
*/
|
|
59
|
+
function createDataClient(sdk: CentraliSDK, centraliUrl: string, workspaceId: string, pathSuffix: string): AxiosInstance {
|
|
60
|
+
const url = new URL(centraliUrl);
|
|
61
|
+
const hostname = url.hostname.startsWith("api.")
|
|
62
|
+
? url.hostname
|
|
63
|
+
: `api.${url.hostname}`;
|
|
64
|
+
const baseURL = `${url.protocol}//${hostname}/data/workspace/${workspaceId}/api/v1/${pathSuffix}`;
|
|
65
|
+
|
|
66
|
+
const client = axios.create({ baseURL });
|
|
67
|
+
|
|
68
|
+
client.interceptors.request.use(async (config) => {
|
|
69
|
+
const token = await ensureToken(sdk);
|
|
70
|
+
if (token) {
|
|
71
|
+
config.headers.Authorization = `Bearer ${token}`;
|
|
72
|
+
}
|
|
73
|
+
return config;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
client.interceptors.response.use(
|
|
77
|
+
(response) => response,
|
|
78
|
+
async (error) => {
|
|
79
|
+
const originalRequest = error.config;
|
|
80
|
+
const isAuthError = error.response?.status === 401 || error.response?.status === 403;
|
|
81
|
+
|
|
82
|
+
if (isAuthError && !originalRequest._hasRetried) {
|
|
83
|
+
originalRequest._hasRetried = true;
|
|
84
|
+
try {
|
|
85
|
+
await sdk.functions.list({ limit: 1 });
|
|
86
|
+
} catch { /* token refresh side effect */ }
|
|
87
|
+
|
|
88
|
+
const token = sdk.getToken();
|
|
89
|
+
if (token) {
|
|
90
|
+
originalRequest.headers.Authorization = `Bearer ${token}`;
|
|
91
|
+
return client.request(originalRequest);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return Promise.reject(error);
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return client;
|
|
99
|
+
}
|
|
100
|
+
|
|
26
101
|
export function registerStructureTools(server: McpServer, sdk: CentraliSDK) {
|
|
27
102
|
server.tool(
|
|
28
103
|
"list_structures",
|
|
@@ -84,7 +159,9 @@ export function registerStructureTools(server: McpServer, sdk: CentraliSDK) {
|
|
|
84
159
|
);
|
|
85
160
|
}
|
|
86
161
|
|
|
87
|
-
export function registerCollectionTools(server: McpServer, sdk: CentraliSDK) {
|
|
162
|
+
export function registerCollectionTools(server: McpServer, sdk: CentraliSDK, centraliUrl: string, workspaceId: string) {
|
|
163
|
+
const getCollectionsClient = () => createDataClient(sdk, centraliUrl, workspaceId, "collections");
|
|
164
|
+
|
|
88
165
|
server.tool(
|
|
89
166
|
"list_collections",
|
|
90
167
|
"List all data collections (schemas) in the Centrali workspace. Returns name, slug, description, and property definitions for each collection.",
|
|
@@ -192,9 +269,141 @@ export function registerCollectionTools(server: McpServer, sdk: CentraliSDK) {
|
|
|
192
269
|
}
|
|
193
270
|
);
|
|
194
271
|
|
|
272
|
+
// ── Schema Update Workflow ─────────────────────────────────────────
|
|
273
|
+
//
|
|
274
|
+
// Updating a collection schema is a multi-step process because changes
|
|
275
|
+
// may require migrating existing records. The workflow is:
|
|
276
|
+
//
|
|
277
|
+
// 1. analyze_collection_update — classify changes, get suggested fixes
|
|
278
|
+
// 2. preview_collection_migration — (optional) test fixes on sample records
|
|
279
|
+
// 3. update_collection — apply the update, with migration plan if needed
|
|
280
|
+
// 4. get_collection_upgrade_progress — poll async migration status
|
|
281
|
+
//
|
|
282
|
+
// For simple changes (adding optional fields, renaming, metadata-only),
|
|
283
|
+
// update_collection handles everything automatically. For breaking or
|
|
284
|
+
// critical changes, analyze first to understand what's needed.
|
|
285
|
+
|
|
286
|
+
server.tool(
|
|
287
|
+
"analyze_collection_update",
|
|
288
|
+
`Analyze proposed schema changes BEFORE applying them. Returns whether migration is needed, what changes are breaking/critical, suggested fixes, and estimated duration.
|
|
289
|
+
|
|
290
|
+
WHEN TO USE: Call this before update_collection when you are changing properties (adding required fields, changing types, renaming fields, deleting fields, or toggling isSecret). This tells you exactly what will happen and what migration plan is needed.
|
|
291
|
+
|
|
292
|
+
CHANGE CATEGORIES:
|
|
293
|
+
- Non-breaking (no migration): adding optional fields, metadata changes → update_collection handles directly
|
|
294
|
+
- Breaking (migration, skippable on non-strict): field renames, deletions, type changes, constraint changes → can skip on schemaless/auto-evolving collections
|
|
295
|
+
- Critical (migration, NEVER skippable): adding required fields without defaults, toggling isSecret → must provide a migration plan with fixes
|
|
296
|
+
|
|
297
|
+
RESPONSE FIELDS:
|
|
298
|
+
- needsMigration: whether existing records need to be updated
|
|
299
|
+
- canSkip: whether migration can be skipped (only false for critical changes or strict schemas with breaking changes)
|
|
300
|
+
- recordCount: number of existing records affected
|
|
301
|
+
- classification: detailed breakdown of all changes by category
|
|
302
|
+
- suggestedFixes: auto-generated fixes you can use in update_collection's migrationPlan
|
|
303
|
+
- estimatedDuration: 'instant' | 'seconds' | 'minutes' | 'long'`,
|
|
304
|
+
{
|
|
305
|
+
collectionId: z.string().describe("The collection ID (UUID) to analyze"),
|
|
306
|
+
properties: z
|
|
307
|
+
.array(z.record(z.string(), z.any()))
|
|
308
|
+
.describe("The COMPLETE new properties array (all fields, not just changed ones). Must include existing property IDs for fields being kept/modified."),
|
|
309
|
+
},
|
|
310
|
+
async ({ collectionId, properties }) => {
|
|
311
|
+
try {
|
|
312
|
+
const client = getCollectionsClient();
|
|
313
|
+
const result = await client.post(`/${collectionId}/analyze-update`, { properties });
|
|
314
|
+
const data = result.data;
|
|
315
|
+
|
|
316
|
+
// Build a human-readable summary for the LLM
|
|
317
|
+
const lines: string[] = [];
|
|
318
|
+
lines.push(`## Analysis for collection ${collectionId}`);
|
|
319
|
+
lines.push(`- Records affected: ${data.recordCount}`);
|
|
320
|
+
lines.push(`- Needs migration: ${data.needsMigration}`);
|
|
321
|
+
lines.push(`- Can skip migration: ${data.canSkip}`);
|
|
322
|
+
lines.push(`- Estimated duration: ${data.estimatedDuration}`);
|
|
323
|
+
|
|
324
|
+
if (!data.needsMigration) {
|
|
325
|
+
lines.push(`\n✅ No migration needed. You can call update_collection directly.`);
|
|
326
|
+
} else if (data.canSkip) {
|
|
327
|
+
lines.push(`\n⚠️ Migration can be skipped. Call update_collection without a migrationPlan to skip, or provide one for a clean migration.`);
|
|
328
|
+
} else {
|
|
329
|
+
lines.push(`\n🔴 Migration REQUIRED and cannot be skipped. You must call update_collection with a migrationPlan.`);
|
|
330
|
+
lines.push(`Use mode 'auto' to apply suggested fixes, or mode 'manual' with custom fixes.`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (data.suggestedFixes && data.suggestedFixes.length > 0) {
|
|
334
|
+
lines.push(`\n### Suggested fixes (use these in migrationPlan.fixes or mode 'auto'):`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
lines.push(`\n### Full analysis:`);
|
|
338
|
+
lines.push(JSON.stringify(data, null, 2));
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
342
|
+
};
|
|
343
|
+
} catch (error: unknown) {
|
|
344
|
+
return {
|
|
345
|
+
content: [{ type: "text", text: formatError(error, `analyzing update for collection '${collectionId}'`) }],
|
|
346
|
+
isError: true,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
server.tool(
|
|
353
|
+
"preview_collection_migration",
|
|
354
|
+
`Preview the effect of migration fixes on sample records BEFORE applying them. Shows before/after snapshots so you can verify fixes are correct.
|
|
355
|
+
|
|
356
|
+
WHEN TO USE: After analyze_collection_update returns suggestedFixes, call this to see how those fixes would transform actual records. Useful for validating type conversions, default values, and expressions before committing.`,
|
|
357
|
+
{
|
|
358
|
+
collectionId: z.string().describe("The collection ID (UUID)"),
|
|
359
|
+
fixes: z
|
|
360
|
+
.array(z.record(z.string(), z.any()))
|
|
361
|
+
.describe("Array of migration fixes to preview (from analyze_collection_update suggestedFixes or custom fixes)"),
|
|
362
|
+
properties: z
|
|
363
|
+
.array(z.record(z.string(), z.any()))
|
|
364
|
+
.describe("The COMPLETE new properties array (same as passed to analyze_collection_update)"),
|
|
365
|
+
},
|
|
366
|
+
async ({ collectionId, fixes, properties }) => {
|
|
367
|
+
try {
|
|
368
|
+
const client = getCollectionsClient();
|
|
369
|
+
const result = await client.post(`/${collectionId}/preview-fixes`, { fixes, properties });
|
|
370
|
+
return {
|
|
371
|
+
content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
|
|
372
|
+
};
|
|
373
|
+
} catch (error: unknown) {
|
|
374
|
+
return {
|
|
375
|
+
content: [{ type: "text", text: formatError(error, `previewing fixes for collection '${collectionId}'`) }],
|
|
376
|
+
isError: true,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
);
|
|
381
|
+
|
|
195
382
|
server.tool(
|
|
196
383
|
"update_collection",
|
|
197
|
-
|
|
384
|
+
`Update a collection's schema using the safe upgrade endpoint. This handles both simple updates and complex migrations.
|
|
385
|
+
|
|
386
|
+
HOW IT WORKS:
|
|
387
|
+
1. If your changes don't require migration (adding optional fields, metadata changes) → completes immediately
|
|
388
|
+
2. If migration is required but you don't provide a migrationPlan → returns an error telling you what's needed
|
|
389
|
+
3. If migration is required and you provide migrationPlan → starts the migration (may be async for large collections)
|
|
390
|
+
|
|
391
|
+
RECOMMENDED WORKFLOW:
|
|
392
|
+
1. Call analyze_collection_update first to understand what changes require
|
|
393
|
+
2. If needsMigration=false → call this tool without migrationPlan
|
|
394
|
+
3. If needsMigration=true and canSkip=true → call this tool without migrationPlan to skip migration
|
|
395
|
+
4. If needsMigration=true and canSkip=false → call this tool WITH migrationPlan
|
|
396
|
+
5. If status='in_progress' → poll with get_collection_upgrade_progress
|
|
397
|
+
|
|
398
|
+
MIGRATION PLAN:
|
|
399
|
+
- mode 'auto': Uses system-generated fixes from analyze_collection_update (handles renames, type conversions, deletions automatically)
|
|
400
|
+
- mode 'manual': Provide your own fixes array for full control over how records are transformed
|
|
401
|
+
|
|
402
|
+
FIX FORMAT (for manual mode):
|
|
403
|
+
Each fix is an object with: { fieldName, fixType, value?, expression?, applyToAll?, filterConditions? }
|
|
404
|
+
- fixType: 'static_value' (set a fixed value), 'expression' (JS expression with 'record' variable), 'auto' (system handles it)
|
|
405
|
+
- For new required fields: provide a default value or expression
|
|
406
|
+
- For type changes: provide a conversion expression`,
|
|
198
407
|
{
|
|
199
408
|
collectionId: z.string().describe("The collection ID (UUID) to update"),
|
|
200
409
|
name: z.string().optional().describe("Updated display name"),
|
|
@@ -202,7 +411,7 @@ export function registerCollectionTools(server: McpServer, sdk: CentraliSDK) {
|
|
|
202
411
|
properties: z
|
|
203
412
|
.array(z.record(z.string(), z.any()))
|
|
204
413
|
.optional()
|
|
205
|
-
.describe("
|
|
414
|
+
.describe("The COMPLETE new properties array. Must include ALL fields (existing + new). Include property 'id' for existing fields being kept/modified."),
|
|
206
415
|
enableVersioning: z.boolean().optional().describe("Enable or disable record versioning"),
|
|
207
416
|
tags: z.array(z.string()).optional().describe("Updated tags"),
|
|
208
417
|
defaultTtlSeconds: z
|
|
@@ -210,30 +419,85 @@ export function registerCollectionTools(server: McpServer, sdk: CentraliSDK) {
|
|
|
210
419
|
.nullable()
|
|
211
420
|
.optional()
|
|
212
421
|
.describe("Default TTL in seconds for new records. Set to null to clear."),
|
|
422
|
+
migrationPlan: z
|
|
423
|
+
.object({
|
|
424
|
+
mode: z.enum(["auto", "manual"]).describe("'auto' uses suggested fixes, 'manual' uses your custom fixes array"),
|
|
425
|
+
fixes: z
|
|
426
|
+
.array(z.record(z.string(), z.any()))
|
|
427
|
+
.optional()
|
|
428
|
+
.describe("Required for 'manual' mode. Array of fix objects specifying how to transform records."),
|
|
429
|
+
})
|
|
430
|
+
.optional()
|
|
431
|
+
.describe("Migration plan for breaking/critical changes. Omit to skip migration (only works if canSkip=true from analyze). Required when canSkip=false."),
|
|
213
432
|
},
|
|
214
|
-
async ({ collectionId, name, description, properties, enableVersioning, tags, defaultTtlSeconds }) => {
|
|
433
|
+
async ({ collectionId, name, description, properties, enableVersioning, tags, defaultTtlSeconds, migrationPlan }) => {
|
|
215
434
|
try {
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
if (
|
|
219
|
-
if (
|
|
220
|
-
if (
|
|
221
|
-
if (
|
|
222
|
-
if (
|
|
223
|
-
|
|
435
|
+
const client = getCollectionsClient();
|
|
436
|
+
const body: Record<string, any> = {};
|
|
437
|
+
if (name !== undefined) body.name = name;
|
|
438
|
+
if (description !== undefined) body.description = description;
|
|
439
|
+
if (properties !== undefined) body.properties = properties;
|
|
440
|
+
if (enableVersioning !== undefined) body.enableVersioning = enableVersioning;
|
|
441
|
+
if (tags !== undefined) body.tags = tags;
|
|
442
|
+
if (defaultTtlSeconds !== undefined) body.defaultTtlSeconds = defaultTtlSeconds;
|
|
443
|
+
if (migrationPlan !== undefined) body.migrationPlan = migrationPlan;
|
|
444
|
+
|
|
445
|
+
const result = await client.post(`/${collectionId}/upgrade`, body);
|
|
446
|
+
const data = result.data;
|
|
447
|
+
|
|
448
|
+
if (data.status === 'completed') {
|
|
449
|
+
return {
|
|
450
|
+
content: [{
|
|
451
|
+
type: "text",
|
|
452
|
+
text: `Collection updated successfully.\n\n${JSON.stringify(data.structure, null, 2)}`,
|
|
453
|
+
}],
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (data.status === 'in_progress') {
|
|
458
|
+
return {
|
|
459
|
+
content: [{
|
|
460
|
+
type: "text",
|
|
461
|
+
text: [
|
|
462
|
+
`Migration started (async). Records are being updated in the background.`,
|
|
463
|
+
`- Job ID: ${data.jobId}`,
|
|
464
|
+
`- Progress URL: ${data.progressUrl}`,
|
|
465
|
+
``,
|
|
466
|
+
`Call get_collection_upgrade_progress with collectionId='${collectionId}' and jobId='${data.jobId}' to check status.`,
|
|
467
|
+
].join("\n"),
|
|
468
|
+
}],
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
224
472
|
return {
|
|
225
|
-
content: [
|
|
226
|
-
{ type: "text", text: JSON.stringify(result.data, null, 2) },
|
|
227
|
-
],
|
|
473
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
228
474
|
};
|
|
229
475
|
} catch (error: unknown) {
|
|
230
476
|
return {
|
|
231
|
-
content: [
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
477
|
+
content: [{ type: "text", text: formatError(error, `updating collection '${collectionId}'`) }],
|
|
478
|
+
isError: true,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
server.tool(
|
|
485
|
+
"get_collection_upgrade_progress",
|
|
486
|
+
"Check the progress of an async collection migration/upgrade job. Call this after update_collection returns status='in_progress'.",
|
|
487
|
+
{
|
|
488
|
+
collectionId: z.string().describe("The collection ID (UUID)"),
|
|
489
|
+
jobId: z.string().describe("The job ID returned by update_collection"),
|
|
490
|
+
},
|
|
491
|
+
async ({ collectionId, jobId }) => {
|
|
492
|
+
try {
|
|
493
|
+
const client = getCollectionsClient();
|
|
494
|
+
const result = await client.get(`/${collectionId}/upgrade/${jobId}/progress`);
|
|
495
|
+
return {
|
|
496
|
+
content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
|
|
497
|
+
};
|
|
498
|
+
} catch (error: unknown) {
|
|
499
|
+
return {
|
|
500
|
+
content: [{ type: "text", text: formatError(error, `getting upgrade progress for '${collectionId}'`) }],
|
|
237
501
|
isError: true,
|
|
238
502
|
};
|
|
239
503
|
}
|