@centrali-io/centrali-mcp 4.2.14 → 4.2.16

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.
@@ -941,7 +941,19 @@ function registerDescribeTools(server) {
941
941
  "Use generate_starter_pages to auto-generate page proposals from your collections",
942
942
  "Always validate_page before publish_page — publishing will reject if there are errors",
943
943
  "Set access policy with set_page_access_policy before publishing if you need auth",
944
+ "Use variable bindings on data sources to scope data dynamically — bind to URL params, auth context, record context, or static defaults",
945
+ "For list→detail navigation: set useQueryParams:true on navigate-to-page actions, then bind the detail page's data source variables to { source: 'url', param: 'id' }",
946
+ "For user-scoped views (e.g., My Approvals): bind a variable to { source: 'auth', field: 'userId' } and use a smart query with {{assigneeId}}",
944
947
  ],
948
+ multi_page_pattern: {
949
+ description: "How to build a list→detail app with scoped related data",
950
+ steps: [
951
+ "1. Create a smart query with {{variables}} for the detail page's related data (e.g., filter by {{requestId}})",
952
+ "2. Create a list page with a data-table block and a navigate-to-page action: set config.useQueryParams:true, paramConfig: { source:'row', mode:'selected', selectedFields:['id'] }",
953
+ "3. Create a detail page with: (a) a record-card block with mode:'single' and variables: { id: { source:'url', param:'id' } }, (b) a related-list block with dataSource type:'query', ref:smartQueryId, variables: { requestId: { source:'url', param:'id' } }",
954
+ "4. Publish both pages. Clicking a row on the list navigates to /ws/detail-slug?id=rowId, and the detail page scopes all blocks to that ID.",
955
+ ],
956
+ },
945
957
  }, null, 2),
946
958
  },
947
959
  ],
@@ -1008,9 +1020,10 @@ function registerDescribeTools(server) {
1008
1020
  filterableColumns: "string[] | null — which columns users can filter on (for data-table blocks)",
1009
1021
  },
1010
1022
  data_source_shape: {
1011
- type: "'structure' — the data source type",
1012
- ref: "string — the collection ID (UUID)",
1013
- recordSlug: "string | undefinedthe collection's record slug for direct resolution (avoids an extra lookup). Recommended.",
1023
+ type: "'structure' | 'query' 'structure' fetches collection records directly, 'query' executes a smart query with {{variable}} substitution",
1024
+ ref: "string — the collection ID (for structure) or smart query ID (for query) — UUID",
1025
+ mode: "'list' | 'single' | 'aggregate' optional. 'single' fetches one record (detail pages), 'list' fetches paginated records, 'aggregate' computes metrics",
1026
+ recordSlug: "string | undefined — the collection's record slug for direct resolution (avoids an extra lookup). Recommended for structure type.",
1014
1027
  config: {
1015
1028
  description: "Optional query configuration",
1016
1029
  filter: "object | null — same filter syntax as query_records",
@@ -1018,12 +1031,27 @@ function registerDescribeTools(server) {
1018
1031
  page: "number | null — for pagination",
1019
1032
  pageSize: "number | null — records per page",
1020
1033
  },
1034
+ variables: {
1035
+ description: "Optional variable bindings map. Each key is a variable name, each value declares the runtime source.",
1036
+ example: "{ requestId: { source: 'url', param: 'id' }, status: { source: 'static', value: 'active' } }",
1037
+ sources: {
1038
+ url: "{ source: 'url', param: 'paramName' } — reads from URL query params (e.g., ?id=abc)",
1039
+ auth: "{ source: 'auth', field: 'userId' | 'email' | 'name' } — from the logged-in user",
1040
+ record: "{ source: 'record', field: 'fieldName' } — from the page's primary record (detail pages only)",
1041
+ static: "{ source: 'static', value: 'literalValue' } — a hardcoded default",
1042
+ },
1043
+ behavior: {
1044
+ query_type: "For type:'query' — resolved variables substitute into smart query {{placeholders}} before execution",
1045
+ structure_type: "For type:'structure' — resolved variables become equality filters on the collection",
1046
+ unresolved: "If any variable cannot be resolved, the block returns an empty result with a variableError message — NEVER unfiltered data",
1047
+ },
1048
+ precedence: "url > record > auth > static (highest to lowest priority)",
1049
+ },
1021
1050
  aggregation: {
1022
1051
  description: "For metric/chart blocks. Defines computations over the data.",
1023
1052
  operations: "Record<string, { count?: '*', sum?: 'fieldName', avg?: 'fieldName', min?: 'fieldName', max?: 'fieldName' }>",
1024
1053
  groupBy: "string[] | null — fields to group by",
1025
1054
  },
1026
- mode: "'list' | 'single' | 'aggregate' — optional. Set to 'aggregate' for metric-card and chart blocks that use aggregation. For list and detail pages, mode is inferred from context.",
1027
1055
  },
1028
1056
  narrative_shape: {
1029
1057
  primaryQuestion: "string — the business question this block answers (e.g., 'How are sales trending?')",
@@ -1212,7 +1240,7 @@ function registerDescribeTools(server) {
1212
1240
  type: "'text' | 'number' | 'email' | 'date' | 'boolean' | 'select' | 'textarea' | 'hidden'",
1213
1241
  required: "boolean (default: false) — ignored for hidden fields",
1214
1242
  placeholder: "string | null",
1215
- defaultValue: "string | number | boolean | { source: 'auth' | 'system', field: string } — static value or derived default. For hidden fields, this is the value injected at submit time. For visible fields, this pre-populates the input. Derived sources: auth.userId, auth.email, auth.name (from logged-in user), system.now (server timestamp). Derived defaults are resolved server-side.",
1243
+ defaultValue: "string | number | boolean | { source: 'auth' | 'system', field: string } — static value or derived default. For hidden fields, this is the value injected at submit time. For visible fields, this pre-populates the input. Derived sources: auth.userId, auth.email, auth.name (from logged-in user's JWT), system.now (server timestamp), system.uuid (random UUID). Derived defaults are resolved server-side.",
1216
1244
  resolveErrorMessage: "string | null — custom error message shown when a derived defaultValue cannot be resolved (e.g. user not signed in). Defaults to 'This form requires you to be signed in' for auth source.",
1217
1245
  options: "For 'select' type: { label: string, value: string }[] — inline static options",
1218
1246
  optionSource: "For 'select' type (alternative): { type: 'static' | 'dynamic', staticOptions?: [{label, value}], dynamicRef?: 'collection-uuid', labelField?, valueField? }",
@@ -61,20 +61,37 @@ function createPagesClient(sdk, centraliUrl, workspaceId) {
61
61
  const client = axios_1.default.create({ baseURL });
62
62
  // Attach the SDK's bearer token to every request
63
63
  client.interceptors.request.use((config) => __awaiter(this, void 0, void 0, function* () {
64
- var _a, _b, _c, _d, _e;
64
+ var _a, _b, _c;
65
65
  const token = (_c = (_b = (_a = sdk).getToken) === null || _b === void 0 ? void 0 : _b.call(_a)) !== null && _c !== void 0 ? _c : sdk.token;
66
- if (!token) {
67
- // Force the SDK to fetch a token by calling getTokenOrFetch
68
- const freshToken = yield ((_e = (_d = sdk).getTokenOrFetch) === null || _e === void 0 ? void 0 : _e.call(_d));
69
- if (freshToken) {
70
- config.headers.Authorization = `Bearer ${freshToken}`;
71
- }
72
- }
73
- else {
66
+ if (token) {
74
67
  config.headers.Authorization = `Bearer ${token}`;
75
68
  }
76
69
  return config;
77
70
  }));
71
+ // Retry on 401/403 after refreshing the token via the SDK
72
+ client.interceptors.response.use((response) => response, (error) => __awaiter(this, void 0, void 0, function* () {
73
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
74
+ const originalRequest = error.config;
75
+ 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;
76
+ if (isAuthError && !originalRequest._hasRetried) {
77
+ originalRequest._hasRetried = true;
78
+ // Force SDK to fetch a fresh token
79
+ const freshToken = yield ((_d = (_c = sdk).getTokenOrFetch) === null || _d === void 0 ? void 0 : _d.call(_c));
80
+ if (!freshToken) {
81
+ // SDK can't get a token either — trigger a re-auth by making any SDK call
82
+ try {
83
+ yield ((_f = (_e = sdk.axios) === null || _e === void 0 ? void 0 : _e.get) === null || _f === void 0 ? void 0 : _f.call(_e, '/health'));
84
+ }
85
+ catch ( /* ignore — we just want the token refresh side effect */_k) { /* ignore — we just want the token refresh side effect */ }
86
+ }
87
+ const token = (_j = (_h = (_g = sdk).getToken) === null || _h === void 0 ? void 0 : _h.call(_g)) !== null && _j !== void 0 ? _j : sdk.token;
88
+ if (token) {
89
+ originalRequest.headers.Authorization = `Bearer ${token}`;
90
+ return client.request(originalRequest);
91
+ }
92
+ }
93
+ return Promise.reject(error);
94
+ }));
78
95
  return { client, workspaceId };
79
96
  }
80
97
  function registerPageTools(server, sdk, centraliUrl, workspaceId) {
@@ -125,7 +142,9 @@ function registerPageTools(server, sdk, centraliUrl, workspaceId) {
125
142
  };
126
143
  }
127
144
  }));
128
- server.tool("create_page", "Create a new page in the workspace. A page is a UI view backed by data from structures. Specify the page type: 'list' for data tables, 'detail' for single-record views, 'form' for data entry, 'dashboard' for metrics and charts.", {
145
+ server.tool("create_page", `Create a new page in the workspace. A page is a UI view backed by data from structures. Specify the page type: 'list' for data tables, 'detail' for single-record views, 'form' for data entry, 'dashboard' for metrics and charts.
146
+
147
+ Navigate-to-page actions can use config.useQueryParams: true to pass selected row fields as URL query params to the target page. Pair with paramConfig: { source: 'row', mode: 'selected', selectedFields: ['id'] } to control which fields are passed. The target detail page can then use variable bindings with { source: 'url', param: 'id' } to read those params.`, {
129
148
  name: zod_1.z.string().describe("Display name for the page (e.g., 'Customer List')"),
130
149
  slug: zod_1.z.string().describe("URL-safe slug (e.g., 'customer-list')"),
131
150
  pageType: zod_1.z
@@ -192,7 +211,24 @@ function registerPageTools(server, sdk, centraliUrl, workspaceId) {
192
211
  }
193
212
  }));
194
213
  // ── Drafts & Versions ─────────────────────────────────────────────
195
- server.tool("save_page_draft", "Save or update the draft definition for a page. The definition describes the page's layout: sections, blocks, data sources, actions, and presentation. This does NOT publish the page — use publish_page after saving.", {
214
+ server.tool("save_page_draft", `Save or update the draft definition for a page. The definition describes the page's layout: sections, blocks, data sources, actions, and presentation. This does NOT publish the page — use publish_page after saving.
215
+
216
+ Each block's dataSource can include an optional 'variables' map for runtime variable binding:
217
+ variables: { [varName]: { source, param?, field?, value? } }
218
+
219
+ Variable binding sources:
220
+ - { source: 'url', param: 'id' } — read from URL query param
221
+ - { source: 'auth', field: 'userId' | 'email' | 'name' } — from authenticated user
222
+ - { source: 'record', field: 'fieldName' } — from page's primary record (detail pages)
223
+ - { source: 'static', value: 'active' } — literal default value
224
+
225
+ For query data sources: variables substitute into smart query {{placeholders}}.
226
+ For structure data sources: variables become equality filters.
227
+
228
+ Common patterns:
229
+ - Detail page related list: { requestId: { source: 'url', param: 'id' } }
230
+ - User-scoped view: { assigneeId: { source: 'auth', field: 'userId' } }
231
+ - Navigate with params: action config { useQueryParams: true } + paramConfig { source: 'row', mode: 'selected', selectedFields: ['id'] }`, {
196
232
  pageId: zod_1.z.string().describe("The page ID (UUID)"),
197
233
  definition: zod_1.z
198
234
  .record(zod_1.z.string(), zod_1.z.any())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@centrali-io/centrali-mcp",
3
- "version": "4.2.14",
3
+ "version": "4.2.16",
4
4
  "description": "Centrali MCP Server - AI assistant integration for Centrali workspaces",
5
5
  "main": "dist/index.js",
6
6
  "type": "commonjs",
@@ -1088,7 +1088,19 @@ export function registerDescribeTools(server: McpServer) {
1088
1088
  "Use generate_starter_pages to auto-generate page proposals from your collections",
1089
1089
  "Always validate_page before publish_page — publishing will reject if there are errors",
1090
1090
  "Set access policy with set_page_access_policy before publishing if you need auth",
1091
+ "Use variable bindings on data sources to scope data dynamically — bind to URL params, auth context, record context, or static defaults",
1092
+ "For list→detail navigation: set useQueryParams:true on navigate-to-page actions, then bind the detail page's data source variables to { source: 'url', param: 'id' }",
1093
+ "For user-scoped views (e.g., My Approvals): bind a variable to { source: 'auth', field: 'userId' } and use a smart query with {{assigneeId}}",
1091
1094
  ],
1095
+ multi_page_pattern: {
1096
+ description: "How to build a list→detail app with scoped related data",
1097
+ steps: [
1098
+ "1. Create a smart query with {{variables}} for the detail page's related data (e.g., filter by {{requestId}})",
1099
+ "2. Create a list page with a data-table block and a navigate-to-page action: set config.useQueryParams:true, paramConfig: { source:'row', mode:'selected', selectedFields:['id'] }",
1100
+ "3. Create a detail page with: (a) a record-card block with mode:'single' and variables: { id: { source:'url', param:'id' } }, (b) a related-list block with dataSource type:'query', ref:smartQueryId, variables: { requestId: { source:'url', param:'id' } }",
1101
+ "4. Publish both pages. Clicking a row on the list navigates to /ws/detail-slug?id=rowId, and the detail page scopes all blocks to that ID.",
1102
+ ],
1103
+ },
1092
1104
  },
1093
1105
  null,
1094
1106
  2
@@ -1180,9 +1192,10 @@ export function registerDescribeTools(server: McpServer) {
1180
1192
  "string[] | null — which columns users can filter on (for data-table blocks)",
1181
1193
  },
1182
1194
  data_source_shape: {
1183
- type: "'structure' — the data source type",
1184
- ref: "string — the collection ID (UUID)",
1185
- recordSlug: "string | undefinedthe collection's record slug for direct resolution (avoids an extra lookup). Recommended.",
1195
+ type: "'structure' | 'query' 'structure' fetches collection records directly, 'query' executes a smart query with {{variable}} substitution",
1196
+ ref: "string — the collection ID (for structure) or smart query ID (for query) — UUID",
1197
+ mode: "'list' | 'single' | 'aggregate' optional. 'single' fetches one record (detail pages), 'list' fetches paginated records, 'aggregate' computes metrics",
1198
+ recordSlug: "string | undefined — the collection's record slug for direct resolution (avoids an extra lookup). Recommended for structure type.",
1186
1199
  config: {
1187
1200
  description: "Optional query configuration",
1188
1201
  filter: "object | null — same filter syntax as query_records",
@@ -1190,6 +1203,22 @@ export function registerDescribeTools(server: McpServer) {
1190
1203
  page: "number | null — for pagination",
1191
1204
  pageSize: "number | null — records per page",
1192
1205
  },
1206
+ variables: {
1207
+ description: "Optional variable bindings map. Each key is a variable name, each value declares the runtime source.",
1208
+ example: "{ requestId: { source: 'url', param: 'id' }, status: { source: 'static', value: 'active' } }",
1209
+ sources: {
1210
+ url: "{ source: 'url', param: 'paramName' } — reads from URL query params (e.g., ?id=abc)",
1211
+ auth: "{ source: 'auth', field: 'userId' | 'email' | 'name' } — from the logged-in user",
1212
+ record: "{ source: 'record', field: 'fieldName' } — from the page's primary record (detail pages only)",
1213
+ static: "{ source: 'static', value: 'literalValue' } — a hardcoded default",
1214
+ },
1215
+ behavior: {
1216
+ query_type: "For type:'query' — resolved variables substitute into smart query {{placeholders}} before execution",
1217
+ structure_type: "For type:'structure' — resolved variables become equality filters on the collection",
1218
+ unresolved: "If any variable cannot be resolved, the block returns an empty result with a variableError message — NEVER unfiltered data",
1219
+ },
1220
+ precedence: "url > record > auth > static (highest to lowest priority)",
1221
+ },
1193
1222
  aggregation: {
1194
1223
  description:
1195
1224
  "For metric/chart blocks. Defines computations over the data.",
@@ -1197,7 +1226,6 @@ export function registerDescribeTools(server: McpServer) {
1197
1226
  "Record<string, { count?: '*', sum?: 'fieldName', avg?: 'fieldName', min?: 'fieldName', max?: 'fieldName' }>",
1198
1227
  groupBy: "string[] | null — fields to group by",
1199
1228
  },
1200
- mode: "'list' | 'single' | 'aggregate' — optional. Set to 'aggregate' for metric-card and chart blocks that use aggregation. For list and detail pages, mode is inferred from context.",
1201
1229
  },
1202
1230
  narrative_shape: {
1203
1231
  primaryQuestion:
@@ -1409,7 +1437,7 @@ export function registerDescribeTools(server: McpServer) {
1409
1437
  type: "'text' | 'number' | 'email' | 'date' | 'boolean' | 'select' | 'textarea' | 'hidden'",
1410
1438
  required: "boolean (default: false) — ignored for hidden fields",
1411
1439
  placeholder: "string | null",
1412
- defaultValue: "string | number | boolean | { source: 'auth' | 'system', field: string } — static value or derived default. For hidden fields, this is the value injected at submit time. For visible fields, this pre-populates the input. Derived sources: auth.userId, auth.email, auth.name (from logged-in user), system.now (server timestamp). Derived defaults are resolved server-side.",
1440
+ defaultValue: "string | number | boolean | { source: 'auth' | 'system', field: string } — static value or derived default. For hidden fields, this is the value injected at submit time. For visible fields, this pre-populates the input. Derived sources: auth.userId, auth.email, auth.name (from logged-in user's JWT), system.now (server timestamp), system.uuid (random UUID). Derived defaults are resolved server-side.",
1413
1441
  resolveErrorMessage: "string | null — custom error message shown when a derived defaultValue cannot be resolved (e.g. user not signed in). Defaults to 'This form requires you to be signed in' for auth source.",
1414
1442
  options: "For 'select' type: { label: string, value: string }[] — inline static options",
1415
1443
  optionSource: "For 'select' type (alternative): { type: 'static' | 'dynamic', staticOptions?: [{label, value}], dynamicRef?: 'collection-uuid', labelField?, valueField? }",
@@ -54,18 +54,42 @@ function createPagesClient(sdk: CentraliSDK, centraliUrl: string, workspaceId: s
54
54
  // Attach the SDK's bearer token to every request
55
55
  client.interceptors.request.use(async (config) => {
56
56
  const token = (sdk as any).getToken?.() ?? (sdk as any).token;
57
- if (!token) {
58
- // Force the SDK to fetch a token by calling getTokenOrFetch
59
- const freshToken = await (sdk as any).getTokenOrFetch?.();
60
- if (freshToken) {
61
- config.headers.Authorization = `Bearer ${freshToken}`;
62
- }
63
- } else {
57
+ if (token) {
64
58
  config.headers.Authorization = `Bearer ${token}`;
65
59
  }
66
60
  return config;
67
61
  });
68
62
 
63
+ // Retry on 401/403 after refreshing the token via the SDK
64
+ client.interceptors.response.use(
65
+ (response) => response,
66
+ async (error) => {
67
+ const originalRequest = error.config;
68
+ const isAuthError = error.response?.status === 401 || error.response?.status === 403;
69
+
70
+ if (isAuthError && !originalRequest._hasRetried) {
71
+ originalRequest._hasRetried = true;
72
+
73
+ // Force SDK to fetch a fresh token
74
+ const freshToken = await (sdk as any).getTokenOrFetch?.();
75
+ if (!freshToken) {
76
+ // SDK can't get a token either — trigger a re-auth by making any SDK call
77
+ try {
78
+ await (sdk as any).axios?.get?.('/health');
79
+ } catch { /* ignore — we just want the token refresh side effect */ }
80
+ }
81
+
82
+ const token = (sdk as any).getToken?.() ?? (sdk as any).token;
83
+ if (token) {
84
+ originalRequest.headers.Authorization = `Bearer ${token}`;
85
+ return client.request(originalRequest);
86
+ }
87
+ }
88
+
89
+ return Promise.reject(error);
90
+ }
91
+ );
92
+
69
93
  return { client, workspaceId };
70
94
  }
71
95
 
@@ -134,7 +158,9 @@ export function registerPageTools(
134
158
 
135
159
  server.tool(
136
160
  "create_page",
137
- "Create a new page in the workspace. A page is a UI view backed by data from structures. Specify the page type: 'list' for data tables, 'detail' for single-record views, 'form' for data entry, 'dashboard' for metrics and charts.",
161
+ `Create a new page in the workspace. A page is a UI view backed by data from structures. Specify the page type: 'list' for data tables, 'detail' for single-record views, 'form' for data entry, 'dashboard' for metrics and charts.
162
+
163
+ Navigate-to-page actions can use config.useQueryParams: true to pass selected row fields as URL query params to the target page. Pair with paramConfig: { source: 'row', mode: 'selected', selectedFields: ['id'] } to control which fields are passed. The target detail page can then use variable bindings with { source: 'url', param: 'id' } to read those params.`,
138
164
  {
139
165
  name: z.string().describe("Display name for the page (e.g., 'Customer List')"),
140
166
  slug: z.string().describe("URL-safe slug (e.g., 'customer-list')"),
@@ -214,7 +240,24 @@ export function registerPageTools(
214
240
 
215
241
  server.tool(
216
242
  "save_page_draft",
217
- "Save or update the draft definition for a page. The definition describes the page's layout: sections, blocks, data sources, actions, and presentation. This does NOT publish the page — use publish_page after saving.",
243
+ `Save or update the draft definition for a page. The definition describes the page's layout: sections, blocks, data sources, actions, and presentation. This does NOT publish the page — use publish_page after saving.
244
+
245
+ Each block's dataSource can include an optional 'variables' map for runtime variable binding:
246
+ variables: { [varName]: { source, param?, field?, value? } }
247
+
248
+ Variable binding sources:
249
+ - { source: 'url', param: 'id' } — read from URL query param
250
+ - { source: 'auth', field: 'userId' | 'email' | 'name' } — from authenticated user
251
+ - { source: 'record', field: 'fieldName' } — from page's primary record (detail pages)
252
+ - { source: 'static', value: 'active' } — literal default value
253
+
254
+ For query data sources: variables substitute into smart query {{placeholders}}.
255
+ For structure data sources: variables become equality filters.
256
+
257
+ Common patterns:
258
+ - Detail page related list: { requestId: { source: 'url', param: 'id' } }
259
+ - User-scoped view: { assigneeId: { source: 'auth', field: 'userId' } }
260
+ - Navigate with params: action config { useQueryParams: true } + paramConfig { source: 'row', mode: 'selected', selectedFields: ['id'] }`,
218
261
  {
219
262
  pageId: z.string().describe("The page ID (UUID)"),
220
263
  definition: z