/**
* Page Tools for NocoBase MCP
*
* Uses the flowModels API for modern NocoBase pages.
*
* Key features:
* - Route type: "flowPage"
* - Schema component: "FlowRoute"
* - Uses flowModels:save for columns/actions
*/
import { z } from 'zod';
import { uid } from '@formily/shared';
import { NocoBaseClient } from '../client.js';
// =============================================================================
// INPUT SCHEMAS
// =============================================================================
export const CreateMenuGroupSchema = z.object({
title: z.string().min(1).describe('Group title'),
icon: z.string().optional().default('FolderOutlined'),
});
export const CreateFlowPageSchema = z.object({
title: z.string().min(1).describe('Page title'),
parentId: z.string().optional().describe('Parent group ID'),
});
export const AddTableBlockSchema = z.object({
gridUid: z.string().describe('Grid UID from page_create'),
collection: z.string().describe('Collection name'),
});
export const AddColumnSchema = z.object({
tableBlockUid: z.string().describe('Table block UID from addTableBlock'),
collection: z.string().describe('Collection name'),
fieldPath: z.string().describe('Field name/path'),
sortIndex: z.number().optional().default(1),
});
export const AddActionSchema = z.object({
parentUid: z.string().describe('Parent UID (table block for header actions, column for row actions)'),
actionType: z.enum(['addNew', 'view', 'edit', 'delete', 'filter', 'refresh']).describe('Action type'),
collection: z.string().describe('Collection name'),
sortIndex: z.number().optional().default(1),
isRowAction: z.boolean().optional().default(false).describe('True for row actions (View/Edit/Delete in Actions column)'),
});
export const ListRoutesSchema = z.object({
type: z.enum(['desktop', 'mobile']).optional().default('desktop'),
});
export const DeletePageSchema = z.object({
routeId: z.string().describe('Route ID to delete'),
});
// =============================================================================
// TOOL IMPLEMENTATIONS
// =============================================================================
/**
* Create a menu group
*/
export async function createMenuGroup(client: NocoBaseClient, input: any) {
const validated = CreateMenuGroupSchema.parse(input);
const res = await client.createRoute('desktop', {
type: 'group',
title: validated.title,
icon: validated.icon,
});
const routeId = res.data?.data?.id;
return {
success: true,
groupId: routeId,
message: `Group "${validated.title}" created. Use groupId "${routeId}" as parentId.`
};
}
/**
* Create a FlowPage (Modern Page)
*/
export async function createFlowPage(client: NocoBaseClient, input: any) {
const validated = CreateFlowPageSchema.parse(input);
const pageSchemaUid = uid();
const menuSchemaUid = uid();
const tabSchemaUid = uid();
const tabSchemaName = uid();
// 1. Insert FlowRoute schema
await client.insertSchema({
type: 'void',
'x-component': 'FlowRoute',
'x-uid': pageSchemaUid
});
// 2. Create route
const routePayload: any = {
type: 'flowPage',
title: validated.title,
schemaUid: pageSchemaUid,
menuSchemaUid,
enableTabs: false,
children: [{
type: 'tabs',
schemaUid: tabSchemaUid,
tabSchemaName,
hidden: true
}]
};
if (validated.parentId) {
routePayload.parentId = validated.parentId;
}
const routeRes = await client.createRoute('desktop', routePayload);
const routeId = routeRes.data?.data?.id;
// 3. Initialize page flow model
await client.flowModelsSave({
uid: uid(),
async: true,
parentId: pageSchemaUid,
subKey: 'page',
subType: 'object',
use: 'RootPageModel',
stepParams: {},
sortIndex: 0,
flowRegistry: {}
});
// 4. Initialize grid flow model - SAVE THE UID!
const gridUid = uid();
await client.flowModelsSave({
uid: gridUid,
parentId: tabSchemaUid,
subKey: 'grid',
async: true,
subType: 'object',
use: 'BlockGridModel',
stepParams: {},
sortIndex: 0,
flowRegistry: {},
filterManager: []
});
return {
success: true,
routeId,
pageSchemaUid,
tabSchemaUid,
gridUid, // Return gridUid for table_add
url: `/admin/${routeId}`,
message: `FlowPage "${validated.title}" created. Use gridUid with table_add.`
};
}
/**
* Add a Table block to FlowPage
* FIXED: Using exact payload structure from NocoBase UI
*/
export async function addTableBlock(client: NocoBaseClient, input: any) {
const validated = AddTableBlockSchema.parse(input);
const tableBlockUid = uid();
const actionsColumnUid = uid();
// EXACT payload structure captured from NocoBase UI!
await client.flowModelsSave({
uid: tableBlockUid,
use: 'TableBlockModel', // NOT "TableModel"!
subModels: {
columns: [
{
uid: actionsColumnUid,
use: 'TableActionsColumnModel', // NOT "ActionsColumnModel"!
parentId: tableBlockUid,
subKey: 'columns', // NOT "actionsColumn"!
subType: 'array',
stepParams: {},
sortIndex: 0,
flowRegistry: {}
}
]
},
stepParams: {
resourceSettings: { // NOT "collection"!
init: {
dataSourceKey: 'main',
collectionName: validated.collection
}
}
},
parentId: validated.gridUid,
subKey: 'items', // NOT "children"!
subType: 'array',
sortIndex: 1,
flowRegistry: {}
});
return {
success: true,
tableBlockUid,
actionsColumnUid,
collection: validated.collection,
message: `Table block added! Use tableBlockUid to add columns. Use actionsColumnUid for row actions.`
};
}
/**
* Add a column to table
*/
export async function addColumn(client: NocoBaseClient, input: any) {
const validated = AddColumnSchema.parse(input);
const columnUid = uid();
const fieldUid = uid();
await client.flowModelsSave({
uid: columnUid,
use: 'TableColumnModel',
stepParams: {
fieldSettings: {
init: {
dataSourceKey: 'main',
collectionName: validated.collection,
fieldPath: validated.fieldPath
}
},
tableColumnSettings: {
model: { use: 'DisplayTextFieldModel' }
}
},
subModels: {
field: {
uid: fieldUid,
use: 'DisplayTextFieldModel',
props: null,
parentId: columnUid,
subKey: 'field',
subType: 'object',
stepParams: {
popupSettings: {
openView: {
collectionName: validated.collection,
dataSourceKey: 'main'
}
}
},
sortIndex: 0,
flowRegistry: {}
}
},
parentId: validated.tableBlockUid,
subKey: 'columns',
subType: 'array',
sortIndex: validated.sortIndex,
flowRegistry: {}
});
return {
success: true,
columnUid,
fieldPath: validated.fieldPath,
message: `Column "${validated.fieldPath}" added to table`
};
}
/**
* Add action button (AddNew, View, Edit, Delete, Filter, Refresh)
*/
export async function addAction(client: NocoBaseClient, input: any) {
const validated = AddActionSchema.parse(input);
const actionUid = uid();
// Map action type to model
const actionModels: Record<string, string> = {
addNew: 'AddNewActionModel',
view: 'ViewActionModel',
edit: 'EditActionModel',
delete: 'DeleteActionModel',
filter: 'FilterActionModel',
refresh: 'RefreshActionModel'
};
const modelUse = actionModels[validated.actionType];
if (!modelUse) {
throw new Error(`Unknown action type: ${validated.actionType}`);
}
const actionPayload: any = {
uid: actionUid,
use: modelUse,
parentId: validated.parentUid,
subKey: validated.isRowAction ? 'actions' : 'actions',
subType: 'array',
stepParams: {
popupSettings: {
openView: {
collectionName: validated.collection,
dataSourceKey: 'main'
}
}
},
sortIndex: validated.sortIndex,
flowRegistry: {}
};
// Add button styling for row actions
if (validated.isRowAction) {
actionPayload.stepParams.buttonSettings = {
general: {
type: 'link',
icon: null
}
};
}
await client.flowModelsSave(actionPayload);
return {
success: true,
actionUid,
actionType: validated.actionType,
message: `Action "${validated.actionType}" added`
};
}
/**
* List routes
*/
export async function listRoutes(client: NocoBaseClient, input: any) {
const validated = ListRoutesSchema.parse(input);
const res = await client.listRoutes(validated.type || 'desktop', {
tree: true,
sort: ['sort']
});
const routes = res.data?.data || [];
const formatRoute = (route: any, indent: number = 0): string => {
const prefix = ' '.repeat(indent);
let line = `${prefix}• ${route.title || 'Untitled'} (id: ${route.id}, type: ${route.type})`;
if (route.schemaUid) line += ` [schema: ${route.schemaUid}]`;
if (route.children && route.children.length > 0) {
const childLines = route.children.map((c: any) => formatRoute(c, indent + 1));
line += '\n' + childLines.join('\n');
}
return line;
};
return {
success: true,
count: routes.length,
routes,
formatted: routes.map((r: any) => formatRoute(r)).join('\n') || 'No routes'
};
}
/**
* Delete page
*/
export async function deletePage(client: NocoBaseClient, input: any) {
const validated = DeletePageSchema.parse(input);
await client.deleteRoute('desktop', validated.routeId, true);
return {
success: true,
message: `Page ${validated.routeId} deleted`
};
}
/**
* Inspect page - get all flow models with detailed structure
* Returns gridUid, tableBlockUid, actionsColumnUid for easy use
*/
export async function inspectPage(client: NocoBaseClient, input: any) {
const { pageTitle, schemaUid, tabSchemaUid } = input;
const result: any = {
success: true,
input: { pageTitle, schemaUid, tabSchemaUid }
};
// If pageTitle provided, find the route first
if (pageTitle && !schemaUid) {
try {
const routesRes = await client.listRoutes('desktop', { tree: true });
const routes = routesRes.data?.data || [];
const findRoute = (routes: any[], title: string): any => {
for (const r of routes) {
if (r.title === title) return r;
if (r.children) {
const found = findRoute(r.children, title);
if (found) return found;
}
}
return null;
};
const route = findRoute(routes, pageTitle);
if (route) {
result.route = {
id: route.id,
title: route.title,
schemaUid: route.schemaUid,
tabSchemaUid: route.children?.[0]?.schemaUid
};
result.schemaUid = route.schemaUid;
result.tabSchemaUid = route.children?.[0]?.schemaUid;
} else {
result.error = `Page "${pageTitle}" not found`;
return result;
}
} catch (e: any) {
result.routeError = e.message;
}
}
const finalTabSchemaUid = tabSchemaUid || result.tabSchemaUid;
// Get grid model and extract all UIDs
if (finalTabSchemaUid) {
try {
const gridRes = await client.flowModelsFindOne(finalTabSchemaUid, 'grid');
const gridData = gridRes.data?.data;
if (gridData) {
result.gridUid = gridData.uid;
// Extract table blocks from items
const items = gridData.subModels?.items || [];
result.tableBlocks = items.map((block: any) => ({
tableBlockUid: block.uid,
use: block.use,
collection: block.stepParams?.resourceSettings?.init?.collectionName,
columns: (block.subModels?.columns || []).map((col: any) => ({
columnUid: col.uid,
use: col.use,
fieldPath: col.stepParams?.fieldSettings?.init?.fieldPath || 'actions'
})),
// Find actions column specifically
actionsColumnUid: (block.subModels?.columns || [])
.find((c: any) => c.use === 'TableActionsColumnModel')?.uid
}));
// Flatten for easy access to first table
if (result.tableBlocks.length > 0) {
result.firstTable = result.tableBlocks[0];
}
}
} catch (e: any) {
result.gridError = e.message;
}
}
return result;
}