@centrali-io/centrali-mcp 4.2.15 → 4.3.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.
@@ -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' }. Use config.paramMapping to rename fields if source and target use different names (e.g., { requestId: '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,29 @@ 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. IMPORTANT: the variable name 'id' is special — it matches the system record UUID (used for single-record fetch by ID). ALL OTHER variable names filter against user data fields and are automatically prefixed with 'data.' (e.g., variable 'requestId' filters on data.requestId in the JSONB column). System columns like createdAt, updatedAt, status do NOT get the data. prefix.",
1046
+ unresolved: "If any variable cannot be resolved, the block returns an empty result with a variableError message — NEVER unfiltered data",
1047
+ detail_page_pattern: "For a detail page: use variables: { id: { source: 'url', param: 'id' } } on the primary record-card block to fetch by system ID. For related-list blocks, use the actual data field name (e.g., variables: { requestId: { source: 'url', param: 'id' } }) — this filters related records where data.requestId matches the URL param. The 'id' variable fetches a record BY its UUID; other variable names filter records WHERE a field equals the value.",
1048
+ reference_fields: "Reference fields (foreign keys between collections) store the target record's system UUID. To filter a related list by a reference field, bind the variable to the parent record's system ID (from URL param), not a custom field value. Example: approval_steps has a data.requestId field that stores the governance_request's UUID.",
1049
+ },
1050
+ precedence: "url > record > auth > static (highest to lowest priority)",
1051
+ },
1021
1052
  aggregation: {
1022
1053
  description: "For metric/chart blocks. Defines computations over the data.",
1023
1054
  operations: "Record<string, { count?: '*', sum?: 'fieldName', avg?: 'fieldName', min?: 'fieldName', max?: 'fieldName' }>",
1024
1055
  groupBy: "string[] | null — fields to group by",
1025
1056
  },
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
1057
  },
1028
1058
  narrative_shape: {
1029
1059
  primaryQuestion: "string — the business question this block answers (e.g., 'How are sales trending?')",
@@ -1312,11 +1342,65 @@ function registerDescribeTools(server) {
1312
1342
  requires: "content property (string) — the HTML markup",
1313
1343
  category: "content-block",
1314
1344
  },
1345
+ "data-html": {
1346
+ description: "Data-bound HTML block. Write HTML templates with {{fieldName}} placeholders that resolve against a data source. Supports two modes: LIST mode (default) renders the template ONCE PER RECORD — perfect for card grids, custom list layouts, or any repeating template. SINGLE mode renders the template once for a single record (detail pages). Records are auto-flattened so {{firstName}} works directly — no need for {{data.firstName}}. Output is sanitized via DOMPurify. No JavaScript allowed.",
1347
+ dataSource: "Required — type: 'structure' or 'query'. Data is resolved server-side and injected into the template.",
1348
+ requires: "content property (string) — HTML template with {{field}} placeholders. Must have non-empty content to pass publishing validation.",
1349
+ template_syntax: "{{fieldName}} for top-level fields, {{data.status}} for nested. Unresolved placeholders render as empty string.",
1350
+ modes: {
1351
+ list: "Default. Template repeats for EACH record. Write a single card/row template and it renders N times for N records. Example: a styled customer card template produces a grid of cards.",
1352
+ single: "Template renders once for ONE record. Use with mode:'single' and a variable binding like { id: { source: 'url', param: 'id' } } to fetch a specific record.",
1353
+ },
1354
+ example_list_mode: {
1355
+ blockType: "data-html",
1356
+ dataSource: { type: "structure", ref: "<collection-uuid>", recordSlug: "customers", config: { sort: [{ field: "createdAt", direction: "desc" }], limit: 20 } },
1357
+ content: "<div style='display:inline-block; width:280px; margin:8px; padding:16px; border:1px solid #e5e7eb; border-radius:8px; vertical-align:top;'><h3>{{firstName}} {{lastName}}</h3><p style='color:#6b7280;'>{{email}}</p></div>",
1358
+ },
1359
+ example_single_mode: {
1360
+ blockType: "data-html",
1361
+ dataSource: { type: "structure", ref: "<collection-uuid>", mode: "single", config: {}, variables: { id: { source: "url", param: "id" } } },
1362
+ content: "<div style='padding:16px; border:1px solid #e5e7eb; border-radius:8px;'><h2>{{firstName}} {{lastName}}</h2><p>{{email}}</p><p>Company: {{company}}</p></div>",
1363
+ },
1364
+ IMPORTANT: "No iteration syntax needed for lists. Just write a single template — the runtime repeats it automatically for each record. Records are auto-flattened: use {{fieldName}} directly, not {{data.fieldName}}.",
1365
+ category: "data-block",
1366
+ },
1367
+ "image": {
1368
+ description: "Image block. Displays an image from a URL with responsive sizing, lazy loading, optional caption, alignment, and link-on-click.",
1369
+ dataSource: "Not required",
1370
+ requires: "src property (string) — the image URL. Optional: alt, caption, alignment (left|center|right), maxWidth, linkUrl.",
1371
+ category: "media-block",
1372
+ },
1373
+ "video": {
1374
+ description: "Video block. Embeds YouTube/Vimeo videos via iframe or direct MP4/WebM via native <video>. Auto-detects provider from URL.",
1375
+ dataSource: "Not required",
1376
+ requires: "src property (string) — video URL (YouTube, Vimeo, or direct). Optional: autoplay (boolean, muted only), loop, controls (default true), poster, caption.",
1377
+ category: "media-block",
1378
+ },
1379
+ "modal": {
1380
+ description: "Declarative modal block. Invisible by default — renders as an overlay when triggered by an 'open-modal' action. Content can be static HTML, markdown, or data-bound HTML. Supports actions (buttons) inside the modal for confirm/cancel flows. Uses Radix Dialog.",
1381
+ dataSource: "Optional — only needed if contentType is 'data-html'",
1382
+ requires: "content property (string), contentType ('html' | 'markdown' | 'data-html', default 'html'). Optional: title, modalSize ('sm' | 'md' | 'lg' | 'fullscreen', default 'md').",
1383
+ actions: "Optional actions array — renders as buttons at the bottom of the modal. Supports close-modal (close the modal), invoke-trigger, navigate-to-page, start-orchestration, open-modal (open another modal). Use close-modal with targetRef set to this modal's own ID for a 'Cancel' button.",
1384
+ trigger: "Add an 'open-modal' action on another block with targetRef set to this modal block's ID. The modal opens when the action fires.",
1385
+ category: "interactive-block",
1386
+ example_confirm_modal: {
1387
+ blockType: "modal",
1388
+ title: "Confirm Delete",
1389
+ modalSize: "sm",
1390
+ contentType: "html",
1391
+ content: "<p>Are you sure you want to delete this item? This action cannot be undone.</p>",
1392
+ actions: [
1393
+ { id: "cancel", type: "close-modal", label: "Cancel", targetRef: "THIS_MODAL_ID", presentation: { color: "neutral" } },
1394
+ { id: "confirm", type: "invoke-trigger", label: "Delete", targetRef: "TRIGGER_ID", presentation: { color: "destructive" } },
1395
+ ],
1396
+ },
1397
+ },
1315
1398
  },
1316
1399
  block_categories: {
1317
- "data-block": "Blocks that display data from collections (data-table, chart, metric-card, stat-group, field-display, related-list, activity-feed, kanban, calendar, progress-indicator)",
1400
+ "data-block": "Blocks that display data from collections (data-table, chart, metric-card, stat-group, field-display, related-list, activity-feed, kanban, calendar, progress-indicator, data-html)",
1318
1401
  "form-block": "Use 'field-group' blockType with a 'fields' array for form pages. Do NOT use individual field block types (text-input, select, etc.) — the runtime renders field types from the field-group's fields array.",
1319
1402
  "content-block": "Static content blocks (markdown, static-html). Must have non-empty 'content' property.",
1403
+ "media-block": "Media blocks (image, video). Require a 'src' URL property.",
1320
1404
  "layout-block": "Blocks that control page-level behavior (filter-bar)",
1321
1405
  },
1322
1406
  action_mappings: {
@@ -1403,16 +1487,18 @@ function registerDescribeTools(server) {
1403
1487
  targetRef: "Page slug to navigate to",
1404
1488
  typical_activation: "row-click",
1405
1489
  paramConfig: "Use source: 'row', mode: 'selected', selectedFields: ['id'] to pass the record ID to the detail page",
1490
+ paramMapping: "Optional config.paramMapping: { sourceField: 'targetParam' } to rename fields in the URL. E.g., { requestId: 'id' } passes ?id=<value> instead of ?requestId=<value>. Only applies when useQueryParams is true.",
1406
1491
  example: {
1407
1492
  id: "view-detail",
1408
1493
  type: "navigate-to-page",
1409
1494
  label: "View Details",
1410
1495
  targetRef: "customer-detail",
1411
1496
  activation: "row-click",
1497
+ config: { useQueryParams: true, paramMapping: { requestId: "id" } },
1412
1498
  paramConfig: {
1413
1499
  source: "row",
1414
1500
  mode: "selected",
1415
- selectedFields: ["id"],
1501
+ selectedFields: ["requestId"],
1416
1502
  },
1417
1503
  },
1418
1504
  },
@@ -1449,6 +1535,16 @@ function registerDescribeTools(server) {
1449
1535
  typical_activation: "button",
1450
1536
  executionMode: "'fire-and-forget'",
1451
1537
  },
1538
+ "open-modal": {
1539
+ description: "Open a modal block. Client-side only — no backend call. The modal block must be defined on the same page with blockType 'modal'.",
1540
+ targetRef: "The modal block's ID (UUID) — must match a block with blockType 'modal' on this page.",
1541
+ typical_activation: "button",
1542
+ },
1543
+ "close-modal": {
1544
+ description: "Close an open modal. Client-side only.",
1545
+ targetRef: "The modal block's ID (UUID) to close.",
1546
+ typical_activation: "button",
1547
+ },
1452
1548
  },
1453
1549
  tips: [
1454
1550
  "Every action needs an 'id' — use UUIDs for uniqueness",
@@ -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. Use config.paramMapping: { sourceField: 'targetParam' } to rename fields in the URL (e.g., { requestId: 'id' } passes ?id=<value> instead of ?requestId=<value>). 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,26 @@ 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. IMPORTANT: variable name 'id' is special — it fetches the record BY its system UUID. All other variable names filter against data.<fieldName> in the JSONB column (e.g., variable 'requestId' → filters on data.requestId). Reference fields between collections store system UUIDs.
227
+
228
+ Common patterns:
229
+ - Detail page primary record: { id: { source: 'url', param: 'id' } } — fetches single record by system ID
230
+ - Detail page related list: { requestId: { source: 'url', param: 'id' } } — filters where data.requestId = URL param
231
+ - User-scoped view: { assigneeId: { source: 'auth', field: 'userId' } }
232
+ - Navigate with params: action config { useQueryParams: true } + paramConfig { source: 'row', mode: 'selected', selectedFields: ['id'] }
233
+ - Rename params: action config { useQueryParams: true, paramMapping: { requestId: 'id' } } — passes ?id=<value> instead of ?requestId=<value>`, {
196
234
  pageId: zod_1.z.string().describe("The page ID (UUID)"),
197
235
  definition: zod_1.z
198
236
  .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.15",
3
+ "version": "4.3.0",
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' }. Use config.paramMapping to rename fields if source and target use different names (e.g., { requestId: '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,24 @@ 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. IMPORTANT: the variable name 'id' is special — it matches the system record UUID (used for single-record fetch by ID). ALL OTHER variable names filter against user data fields and are automatically prefixed with 'data.' (e.g., variable 'requestId' filters on data.requestId in the JSONB column). System columns like createdAt, updatedAt, status do NOT get the data. prefix.",
1218
+ unresolved: "If any variable cannot be resolved, the block returns an empty result with a variableError message — NEVER unfiltered data",
1219
+ detail_page_pattern: "For a detail page: use variables: { id: { source: 'url', param: 'id' } } on the primary record-card block to fetch by system ID. For related-list blocks, use the actual data field name (e.g., variables: { requestId: { source: 'url', param: 'id' } }) — this filters related records where data.requestId matches the URL param. The 'id' variable fetches a record BY its UUID; other variable names filter records WHERE a field equals the value.",
1220
+ reference_fields: "Reference fields (foreign keys between collections) store the target record's system UUID. To filter a related list by a reference field, bind the variable to the parent record's system ID (from URL param), not a custom field value. Example: approval_steps has a data.requestId field that stores the governance_request's UUID.",
1221
+ },
1222
+ precedence: "url > record > auth > static (highest to lowest priority)",
1223
+ },
1193
1224
  aggregation: {
1194
1225
  description:
1195
1226
  "For metric/chart blocks. Defines computations over the data.",
@@ -1197,7 +1228,6 @@ export function registerDescribeTools(server: McpServer) {
1197
1228
  "Record<string, { count?: '*', sum?: 'fieldName', avg?: 'fieldName', min?: 'fieldName', max?: 'fieldName' }>",
1198
1229
  groupBy: "string[] | null — fields to group by",
1199
1230
  },
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
1231
  },
1202
1232
  narrative_shape: {
1203
1233
  primaryQuestion:
@@ -1518,14 +1548,73 @@ export function registerDescribeTools(server: McpServer) {
1518
1548
  requires: "content property (string) — the HTML markup",
1519
1549
  category: "content-block",
1520
1550
  },
1551
+ "data-html": {
1552
+ description:
1553
+ "Data-bound HTML block. Write HTML templates with {{fieldName}} placeholders that resolve against a data source. Supports two modes: LIST mode (default) renders the template ONCE PER RECORD — perfect for card grids, custom list layouts, or any repeating template. SINGLE mode renders the template once for a single record (detail pages). Records are auto-flattened so {{firstName}} works directly — no need for {{data.firstName}}. Output is sanitized via DOMPurify. No JavaScript allowed.",
1554
+ dataSource: "Required — type: 'structure' or 'query'. Data is resolved server-side and injected into the template.",
1555
+ requires: "content property (string) — HTML template with {{field}} placeholders. Must have non-empty content to pass publishing validation.",
1556
+ template_syntax: "{{fieldName}} for top-level fields, {{data.status}} for nested. Unresolved placeholders render as empty string.",
1557
+ modes: {
1558
+ list: "Default. Template repeats for EACH record. Write a single card/row template and it renders N times for N records. Example: a styled customer card template produces a grid of cards.",
1559
+ single: "Template renders once for ONE record. Use with mode:'single' and a variable binding like { id: { source: 'url', param: 'id' } } to fetch a specific record.",
1560
+ },
1561
+ example_list_mode: {
1562
+ blockType: "data-html",
1563
+ dataSource: { type: "structure", ref: "<collection-uuid>", recordSlug: "customers", config: { sort: [{ field: "createdAt", direction: "desc" }], limit: 20 } },
1564
+ content: "<div style='display:inline-block; width:280px; margin:8px; padding:16px; border:1px solid #e5e7eb; border-radius:8px; vertical-align:top;'><h3>{{firstName}} {{lastName}}</h3><p style='color:#6b7280;'>{{email}}</p></div>",
1565
+ },
1566
+ example_single_mode: {
1567
+ blockType: "data-html",
1568
+ dataSource: { type: "structure", ref: "<collection-uuid>", mode: "single", config: {}, variables: { id: { source: "url", param: "id" } } },
1569
+ content: "<div style='padding:16px; border:1px solid #e5e7eb; border-radius:8px;'><h2>{{firstName}} {{lastName}}</h2><p>{{email}}</p><p>Company: {{company}}</p></div>",
1570
+ },
1571
+ IMPORTANT: "No iteration syntax needed for lists. Just write a single template — the runtime repeats it automatically for each record. Records are auto-flattened: use {{fieldName}} directly, not {{data.fieldName}}.",
1572
+ category: "data-block",
1573
+ },
1574
+ "image": {
1575
+ description:
1576
+ "Image block. Displays an image from a URL with responsive sizing, lazy loading, optional caption, alignment, and link-on-click.",
1577
+ dataSource: "Not required",
1578
+ requires: "src property (string) — the image URL. Optional: alt, caption, alignment (left|center|right), maxWidth, linkUrl.",
1579
+ category: "media-block",
1580
+ },
1581
+ "video": {
1582
+ description:
1583
+ "Video block. Embeds YouTube/Vimeo videos via iframe or direct MP4/WebM via native <video>. Auto-detects provider from URL.",
1584
+ dataSource: "Not required",
1585
+ requires: "src property (string) — video URL (YouTube, Vimeo, or direct). Optional: autoplay (boolean, muted only), loop, controls (default true), poster, caption.",
1586
+ category: "media-block",
1587
+ },
1588
+ "modal": {
1589
+ description:
1590
+ "Declarative modal block. Invisible by default — renders as an overlay when triggered by an 'open-modal' action. Content can be static HTML, markdown, or data-bound HTML. Supports actions (buttons) inside the modal for confirm/cancel flows. Uses Radix Dialog.",
1591
+ dataSource: "Optional — only needed if contentType is 'data-html'",
1592
+ requires: "content property (string), contentType ('html' | 'markdown' | 'data-html', default 'html'). Optional: title, modalSize ('sm' | 'md' | 'lg' | 'fullscreen', default 'md').",
1593
+ actions: "Optional actions array — renders as buttons at the bottom of the modal. Supports close-modal (close the modal), invoke-trigger, navigate-to-page, start-orchestration, open-modal (open another modal). Use close-modal with targetRef set to this modal's own ID for a 'Cancel' button.",
1594
+ trigger: "Add an 'open-modal' action on another block with targetRef set to this modal block's ID. The modal opens when the action fires.",
1595
+ category: "interactive-block",
1596
+ example_confirm_modal: {
1597
+ blockType: "modal",
1598
+ title: "Confirm Delete",
1599
+ modalSize: "sm",
1600
+ contentType: "html",
1601
+ content: "<p>Are you sure you want to delete this item? This action cannot be undone.</p>",
1602
+ actions: [
1603
+ { id: "cancel", type: "close-modal", label: "Cancel", targetRef: "THIS_MODAL_ID", presentation: { color: "neutral" } },
1604
+ { id: "confirm", type: "invoke-trigger", label: "Delete", targetRef: "TRIGGER_ID", presentation: { color: "destructive" } },
1605
+ ],
1606
+ },
1607
+ },
1521
1608
  },
1522
1609
  block_categories: {
1523
1610
  "data-block":
1524
- "Blocks that display data from collections (data-table, chart, metric-card, stat-group, field-display, related-list, activity-feed, kanban, calendar, progress-indicator)",
1611
+ "Blocks that display data from collections (data-table, chart, metric-card, stat-group, field-display, related-list, activity-feed, kanban, calendar, progress-indicator, data-html)",
1525
1612
  "form-block":
1526
1613
  "Use 'field-group' blockType with a 'fields' array for form pages. Do NOT use individual field block types (text-input, select, etc.) — the runtime renders field types from the field-group's fields array.",
1527
1614
  "content-block":
1528
1615
  "Static content blocks (markdown, static-html). Must have non-empty 'content' property.",
1616
+ "media-block":
1617
+ "Media blocks (image, video). Require a 'src' URL property.",
1529
1618
  "layout-block":
1530
1619
  "Blocks that control page-level behavior (filter-bar)",
1531
1620
  },
@@ -1635,16 +1724,19 @@ export function registerDescribeTools(server: McpServer) {
1635
1724
  typical_activation: "row-click",
1636
1725
  paramConfig:
1637
1726
  "Use source: 'row', mode: 'selected', selectedFields: ['id'] to pass the record ID to the detail page",
1727
+ paramMapping:
1728
+ "Optional config.paramMapping: { sourceField: 'targetParam' } to rename fields in the URL. E.g., { requestId: 'id' } passes ?id=<value> instead of ?requestId=<value>. Only applies when useQueryParams is true.",
1638
1729
  example: {
1639
1730
  id: "view-detail",
1640
1731
  type: "navigate-to-page",
1641
1732
  label: "View Details",
1642
1733
  targetRef: "customer-detail",
1643
1734
  activation: "row-click",
1735
+ config: { useQueryParams: true, paramMapping: { requestId: "id" } },
1644
1736
  paramConfig: {
1645
1737
  source: "row",
1646
1738
  mode: "selected",
1647
- selectedFields: ["id"],
1739
+ selectedFields: ["requestId"],
1648
1740
  },
1649
1741
  },
1650
1742
  },
@@ -1691,6 +1783,18 @@ export function registerDescribeTools(server: McpServer) {
1691
1783
  typical_activation: "button",
1692
1784
  executionMode: "'fire-and-forget'",
1693
1785
  },
1786
+ "open-modal": {
1787
+ description:
1788
+ "Open a modal block. Client-side only — no backend call. The modal block must be defined on the same page with blockType 'modal'.",
1789
+ targetRef: "The modal block's ID (UUID) — must match a block with blockType 'modal' on this page.",
1790
+ typical_activation: "button",
1791
+ },
1792
+ "close-modal": {
1793
+ description:
1794
+ "Close an open modal. Client-side only.",
1795
+ targetRef: "The modal block's ID (UUID) to close.",
1796
+ typical_activation: "button",
1797
+ },
1694
1798
  },
1695
1799
  tips: [
1696
1800
  "Every action needs an 'id' — use UUIDs for uniqueness",
@@ -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. Use config.paramMapping: { sourceField: 'targetParam' } to rename fields in the URL (e.g., { requestId: 'id' } passes ?id=<value> instead of ?requestId=<value>). 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,26 @@ 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. IMPORTANT: variable name 'id' is special — it fetches the record BY its system UUID. All other variable names filter against data.<fieldName> in the JSONB column (e.g., variable 'requestId' → filters on data.requestId). Reference fields between collections store system UUIDs.
256
+
257
+ Common patterns:
258
+ - Detail page primary record: { id: { source: 'url', param: 'id' } } — fetches single record by system ID
259
+ - Detail page related list: { requestId: { source: 'url', param: 'id' } } — filters where data.requestId = URL param
260
+ - User-scoped view: { assigneeId: { source: 'auth', field: 'userId' } }
261
+ - Navigate with params: action config { useQueryParams: true } + paramConfig { source: 'row', mode: 'selected', selectedFields: ['id'] }
262
+ - Rename params: action config { useQueryParams: true, paramMapping: { requestId: 'id' } } — passes ?id=<value> instead of ?requestId=<value>`,
218
263
  {
219
264
  pageId: z.string().describe("The page ID (UUID)"),
220
265
  definition: z