@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.
- package/dist/tools/describe.js +102 -6
- package/dist/tools/pages.js +49 -11
- package/package.json +1 -1
- package/src/tools/describe.ts +110 -6
- package/src/tools/pages.ts +54 -9
package/dist/tools/describe.js
CHANGED
|
@@ -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' —
|
|
1012
|
-
ref: "string — the collection ID (UUID
|
|
1013
|
-
|
|
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: ["
|
|
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",
|
package/dist/tools/pages.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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",
|
|
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",
|
|
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
package/src/tools/describe.ts
CHANGED
|
@@ -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' —
|
|
1184
|
-
ref: "string — the collection ID (UUID
|
|
1185
|
-
|
|
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: ["
|
|
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",
|
package/src/tools/pages.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|