@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 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;
@@ -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 += `: [${(_b = (_a = e.code) !== null && _a !== void 0 ? _a : e.status) !== null && _b !== void 0 ? _b : 'ERROR'}] ${e.message}`;
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
- server.tool("update_collection", "Update an existing collection by ID. Only include the fields you want to change.", {
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("Updated array of property definitions (replaces existing properties)"),
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
- }, (_a) => __awaiter(this, [_a], void 0, function* ({ collectionId, name, description, properties, enableVersioning, tags, defaultTtlSeconds }) {
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 input = {};
403
+ const client = getCollectionsClient();
404
+ const body = {};
202
405
  if (name !== undefined)
203
- input.name = name;
406
+ body.name = name;
204
407
  if (description !== undefined)
205
- input.description = description;
408
+ body.description = description;
206
409
  if (properties !== undefined)
207
- input.properties = properties;
410
+ body.properties = properties;
208
411
  if (enableVersioning !== undefined)
209
- input.enableVersioning = enableVersioning;
412
+ body.enableVersioning = enableVersioning;
210
413
  if (tags !== undefined)
211
- input.tags = tags;
414
+ body.tags = tags;
212
415
  if (defaultTtlSeconds !== undefined)
213
- input.defaultTtlSeconds = defaultTtlSeconds;
214
- const result = yield sdk.collections.update(collectionId, input);
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
- type: "text",
226
- text: formatError(error, `updating collection '${collectionId}'`),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@centrali-io/centrali-mcp",
3
- "version": "4.4.7",
3
+ "version": "4.4.8-rc.0",
4
4
  "description": "Centrali MCP Server - AI assistant integration for Centrali workspaces",
5
5
  "main": "dist/index.js",
6
6
  "type": "commonjs",
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);
@@ -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
- "Update an existing collection by ID. Only include the fields you want to change.",
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("Updated array of property definitions (replaces existing properties)"),
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 input: Record<string, any> = {};
217
- if (name !== undefined) input.name = name;
218
- if (description !== undefined) input.description = description;
219
- if (properties !== undefined) input.properties = properties;
220
- if (enableVersioning !== undefined) input.enableVersioning = enableVersioning;
221
- if (tags !== undefined) input.tags = tags;
222
- if (defaultTtlSeconds !== undefined) input.defaultTtlSeconds = defaultTtlSeconds;
223
- const result = await sdk.collections.update(collectionId, input as any);
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
- type: "text",
234
- text: formatError(error, `updating collection '${collectionId}'`),
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
  }