mcp tablas

This commit is contained in:
Jordan Diaz
2026-04-25 08:51:17 +00:00
parent 62239cb0a5
commit e84a36c83d
17 changed files with 535 additions and 497 deletions

View File

@@ -1,85 +0,0 @@
{
"menu": "database",
"_defaultAction": "editTable",
"tableName": "",
"fieldname": "",
"order": 0,
"editField": 1,
"label": "",
"newFieldname": "",
"type": "",
"defaultValue": "",
"defaultContent": "",
"checkedByDefault": 0,
"descriptionjson": {},
"description": "",
"optionsTablename20": "",
"optionsValueField20": "",
"optionsLabelField20": "",
"checkedValue": 1,
"uncheckedValue": 0,
"fieldHeight": 300,
"tablaAuxiliar": 0,
"fieldWidth": null,
"tipoTags": 0,
"tipoAtributo": 0,
"allowUploads": 1,
"wysywigAvanzado": 1,
"yearRangeStart": 2010,
"yearRangeEnd": 2026,
"showTime": 1,
"use24HourFormat": 1,
"showSeconds": 1,
"listType": "pulldown",
"optionsType": "text",
"optionsText": "option one\noption two\noption three",
"optionsTablename": null,
"optionsValueField": null,
"optionsLabelField": null,
"optionsQuery": "SELECT fieldname1, fieldname2 FROM cms_tableName",
"filterField": null,
"separatorType": "blank line",
"separatorHeader": "",
"separatorHTML": "<tr><td colspan='2'></td></tr>",
"isRequired": 0,
"isUnique": 0,
"minLength": null,
"maxLength": null,
"charsetRule": "",
"charset": "",
"allowedExtensions": "gif,jpg,png,wmv,mov,swf,pdf",
"checkMaxUploads": 1,
"maxUploads": 25,
"checkMaxUploadSize": 1,
"maxUploadSizeKB": 5120,
"resizeOversizedImages": 1,
"maxImageWidth": 1024,
"maxImageHeight": 1024,
"createThumbnails": 1,
"maxThumbnailWidth": 150,
"maxThumbnailHeight": 150,
"createThumbnails2": 0,
"maxThumbnailWidth2": 150,
"maxThumbnailHeight2": 150,
"createThumbnails3": 0,
"maxThumbnailWidth3": 150,
"maxThumbnailHeight3": 150,
"createThumbnails4": 0,
"maxThumbnailWidth4": 150,
"maxThumbnailHeight4": 150,
"plUpload": 1,
"isSystemField": 0,
"adminOnly": 0,
"isPasswordField": 0,
"autoFormat": 1,
"infoField1": "",
"infoField2": "",
"infoField3": "",
"infoField4": "",
"infoField5": "",
"useCustomUploadDir": 0,
"customUploadDir": "/var/www/vhosts/ws.cocosolution.com/httpdocs/cms/uploads/",
"customUploadUrl": "/uploads/",
"customColumnType": "",
"save": 1
}

View File

@@ -501,50 +501,6 @@ export class FormParamsBuilder {
return params;
}
static buildTableCreateParams(menuName, tableName, type, enlace, seo_metas, menuOrder) {
return new URLSearchParams({
menu: "database",
_defaultAction: "addTable_save",
type: type,
preset: "",
enlace: enlace ? "on" : "",
seo_metas: seo_metas ? "on" : "",
menuName: menuName,
menuOrder: menuOrder.toString(),
tableName: tableName
});
}
static buildTableDeleteParams(tableName) {
const params = new URLSearchParams();
params.append('menu', 'database');
params.append('action', 'editTable');
params.append('dropTable', '1');
params.append('tableName', tableName);
return params;
}
static buildFieldEditParams(tableName, multipleFields) {
const params = new URLSearchParams();
params.append('menu', 'database');
params.append('_defaultAction', 'editTable');
params.append('editField', '1');
params.append('tableName', tableName);
params.append('save', '1');
params.append('multipleFields', JSON.stringify(multipleFields));
return params;
}
static buildFieldDeleteParams(tableName, fieldname) {
const params = new URLSearchParams();
params.append('menu', 'database');
params.append('action', 'editTable');
params.append('editField', '1');
params.append('tableName', tableName);
params.append('fieldname', fieldname);
params.append('deleteField', '1');
return params;
}
}
/**

View File

@@ -20,16 +20,16 @@ export const createSectionWorkflow = {
{
step: 3,
action: "Create the table",
description: "Create the database table with correct type and configuration.",
description: "Create the database table with correct type and configuration. Pass enlace=true if records need public URLs; pass seoMetas=true if records need SEO meta fields. Those flags are enough — there is no update_table_schema step afterwards.",
tool: "create_table",
critical: "type must be: 'multi' (multiple records), 'single' (one record), 'category' (grouping), or 'separador' (menu separator). Set enlace=true if records need their own URL page."
critical: "menuType must be: 'multi' (multiple records), 'single' (one record), 'category' (grouping), or 'separador' (menu separator). Set enlace=true if records need their own URL page. Set seoMetas=true if you want the SEO meta fields added from the start."
},
{
step: 4,
action: "Add fields to the table",
description: "Create all necessary fields with correct types and configuration.",
tool: "edit_table_field",
critical: "Can batch multiple fields in one call. Field types: textfield, textbox, wysiwyg, date, checkbox, list, upload, multitext, codigo, separator."
description: "Create each necessary field with the correct type. create_field is a single-field operation — call it once per field.",
tool: "create_field",
critical: "One call per field. Field types: textfield, textbox, wysiwyg, date, checkbox, list, upload, multitext, codigo, separator. Pass isRequired / maxLength / listType / etc. via initialProps."
},
{
step: 5,
@@ -41,14 +41,14 @@ export const createSectionWorkflow = {
{
step: 6,
action: "Design and create the listing module",
description: "Create an HTML module that displays a list/grid of records from this section.",
tool: "save_module",
critical: "Use Twig syntax. Access records with the 'get' filter. Primary key is 'num' not 'id'. Upload fields are ALWAYS arrays: use record.field[0].urlPath | imagec(width)."
description: "Create the listing module that displays a list/grid of records. Use create_module to scaffold the folder, then acai-write on index-base.tpl with the Twig (compile runs automatically).",
tool: "create_module",
critical: "Use Twig syntax. Access records with the 'get' filter. Primary key is 'num' not 'id'. Upload fields are ALWAYS arrays: use record.field[0].urlPath | imagec(width). After create_module, use acai-write on index-base.tpl to set the actual template."
},
{
step: 7,
action: "Set module example data",
description: "Set example/static data for module preview. MUST call get_module first to discover ALL variables.",
description: "Set example/static data for module preview. MUST call get_module_config_vars first to discover ALL variables.",
tool: "set_module_example_data",
critical: "Every builder variable must have example data. Missing variables cause blank previews."
},
@@ -57,14 +57,14 @@ export const createSectionWorkflow = {
action: "Add sample content",
description: "Create 2-3 sample records with realistic content and images. If table has enlace=true, include the 'enlace' field with a URL slug.",
tool: "create_or_update_record",
critical: "Date format: YYYY-MM-DD HH:mm:ss. Checkbox: 1 or 0. Upload fields: use upload_record_image separately. For sections with enlace, creating records first ensures directory structure is ready."
critical: "Date format: YYYY-MM-DD HH:mm:ss. Checkbox: 1 or 0. Upload fields: use upload_record_image separately. For sections with enlace, create records BEFORE creating the general section to ensure directory structure is ready."
},
{
step: 9,
action: "Create detail template (if enlace=true)",
description: "If the section has enlace enabled, create the detail page template that shows when navigating to a record's URL.",
tool: "save_general_section",
critical: "Use 'thisrecord' variable to access the current record. Same Twig rules apply. Note: save_general_section will auto-initialize the directory if needed."
action: "Create the general section (detail template) — if enlace=true",
description: "If the table has enlace enabled, create a module named literally 'custom-{tableName}' in template/estandar/modulos/. This module renders every record's URL automatically; there is NO _detailPage field to configure. Use acai-write on template/estandar/modulos/custom-{tableName}/index-base.tpl — it creates the folder and triggers the compile.",
tool: "acai-write",
critical: "Folder name must be EXACTLY 'custom-' + tableName (e.g. table 'vacantes' → 'custom-vacantes'). The CMS routes by this convention. Inside the template access the current record via `thisrecord.fieldname`. Do NOT create a separate listing page in 'apartados' for details, do NOT use query params, do NOT use hooks to fetch the record."
},
{
step: 10,
@@ -77,9 +77,10 @@ export const createSectionWorkflow = {
context: {
twig_filters: "Use 'get' filter for DB queries: {% set items = 'tablename' | get('WHERE active=1', 'ORDER BY num DESC', 10) %}. Use 'imagec' for image resize: {{ path | imagec(400) }}. Use 'module' to include other modules: {{ 'modulename' | module(vars) }}.",
field_types: "textfield (single line), textbox (multiline), wysiwyg (rich HTML), date (YYYY-MM-DD), checkbox (0/1), list (dropdown/radio/checkbox), upload (files/images), multitext (key-value pairs), codigo (code editor), separator (visual divider).",
list_field_config: "Static options: optionsType='text', optionsText='value1|Label 1\\nvalue2|Label 2'. Table relation: optionsType='table', optionsTablename='tablename', optionsValueField='num', optionsLabelField='name'. SQL: optionsType='query', optionsText='SELECT num,name FROM cms_tablename'.",
list_field_config: "Static options: optionsType='text', optionsText='value1|Label 1\\nvalue2|Label 2'. Table relation: optionsType='table', optionsTablename='tablename', optionsValueField='num', optionsLabelField='name'. SQL: optionsType='query', optionsQuery='SELECT num,name FROM cms_tablename'.",
builder_vars: "data-field-type attribute on HTML elements creates editable fields. Types: textfield, headfield, textbox, wysiwyg, link, upload, uploadBackground, uploadMulti, list, multiv2. Variable names derived from labels (lowercase, no spaces/accents).",
upload_rules: "Upload fields ALWAYS return arrays. Single image: {{ record.imagen[0].urlPath | imagec(WIDTH) }}. Gallery loop: {% for img in record.galeria %}{{ img.urlPath }}{% endfor %}. Check existence: {% if record.imagen and record.imagen|length > 0 %}."
upload_rules: "Upload fields ALWAYS return arrays. Single image: {{ record.imagen[0].urlPath | imagec(WIDTH) }}. Gallery loop: {% for img in record.galeria %}{{ img.urlPath }}{% endfor %}. Check existence: {% if record.imagen and record.imagen|length > 0 %}.",
general_section_convention: "For any table with enlace=true, the detail template is a module at template/estandar/modulos/custom-{tableName}/. The CMS binds it automatically — no configuration required. Use `thisrecord.field` inside the Twig to access the record being rendered."
},
rules: [
"Table names WITHOUT 'cms_' prefix in all tool calls",
@@ -91,7 +92,8 @@ export const createSectionWorkflow = {
"Enlace (URL slug): auto-formatted to /path/ with slashes",
"Variable names in modules: lowercase, no spaces, no accents, no special chars",
"c-if='varname' for conditional rendering, c-hidden='true' for invisible config vars",
"When using 'get' filter: SQL string syntax, NOT objects. Example: 'WHERE num > 5'"
"When using 'get' filter: SQL string syntax, NOT objects. Example: 'WHERE num > 5'",
"The general section (record detail) is ALWAYS a module named 'custom-{tableName}' — never a separate page in 'apartados'."
],
warnings: [
"DO NOT use record.imagen.urlPath — it's record.imagen[0].urlPath (array!)",
@@ -99,7 +101,9 @@ export const createSectionWorkflow = {
"DO NOT forget to set example data after creating a module — it will look blank",
"DO NOT create a detail template if enlace is false — there's no URL to navigate to",
"DO NOT use Twig functions like range() — only filters (pipe syntax) are available",
"For best results with new enlace sections, create records BEFORE calling save_general_section to ensure directory structure exists"
"DO NOT configure '_detailPage' or any similar field — it does not exist; routing is by the 'custom-{tableName}' convention.",
"DO NOT create individual pages in 'apartados' for each record — the general section handles all records of the table automatically.",
"For best results with new enlace sections, create records BEFORE creating the general section so the directory structure exists."
],
resources: [
"acai://resources/guia-builder-vars",

View File

@@ -1,58 +1,60 @@
export const seoSetupWorkflow = {
id: "seo_setup",
name: "SEO Setup",
description: "Configure SEO for a section: meta tags, URL slugs, and structured data.",
description: "Configure SEO for a section: meta tags, URL slugs, and detail template.",
steps: [
{
step: 1,
action: "Get current table schema",
description: "Check if seo_metas is already enabled and if enlace (URL slug) exists.",
description: "Check which SEO fields already exist and whether enlace is enabled.",
tool: "get_table_schema",
critical: "Look for seo_metas flag and enlace configuration in the schema response."
critical: "Look for seo_title / seo_description / seo_keywords fields and the enlace field in the schema response."
},
{
step: 2,
action: "Enable SEO meta tags",
description: "Turn on seo_metas in the table schema to add meta title/description fields.",
tool: "update_table_schema",
critical: "Set seo_metas=true in the schema. This adds SEO fields to each record."
action: "Add SEO meta fields if missing",
description: "If seo_title / seo_description / seo_keywords are not present, add them as regular fields. Note: for NEW tables you can instead pass seoMetas=true to create_table and they get added up front.",
tool: "create_field",
critical: "One create_field call per SEO field. Typical set: seo_title (textfield), seo_description (textbox), seo_keywords (textfield)."
},
{
step: 3,
action: "Enable enlace for URL slugs",
description: "Enable enlace so records get their own URL-friendly pages.",
tool: "update_table_schema",
critical: "Set enlace=true. This auto-generates /section/record-name/ URLs for each record."
action: "Add enlace field if missing",
description: "If the table has no enlace field and records need public URLs, add one. For NEW tables pass enlace=true to create_table instead.",
tool: "create_field",
critical: "fieldName='enlace', type='textfield'. Acai auto-formats the value to /section/slug/. Existing records then get URLs based on this field."
},
{
step: 4,
action: "Update records with SEO data",
description: "Fill in SEO fields for each record: meta title, meta description.",
description: "Fill in SEO fields for each record: meta title, meta description, keywords.",
tool: "create_or_update_record",
critical: "SEO fields are typically: seo_title, seo_description. Check the schema for exact field names."
critical: "SEO fields are: seo_title, seo_description, seo_keywords. Check the schema for exact field names before writing."
},
{
step: 5,
action: "Create or update detail template",
description: "Ensure the detail page template includes proper meta tags and structured data.",
tool: "save_general_section",
critical: "The template uses 'thisrecord' variable. Include meta tags in the template for SEO."
action: "Create or update the general section (detail template)",
description: "Ensure the detail page template at template/estandar/modulos/custom-{tableName}/index-base.tpl exists and includes the SEO meta tags. The CMS renders this module automatically on each record URL.",
tool: "acai-write",
critical: "Folder must be EXACTLY 'custom-' + tableName. Inside the Twig, access record data via `thisrecord.seo_title`, `thisrecord.seo_description`, etc. Include these in the <head> via the layout's SEO slot, or inline if the project uses per-section heads."
}
],
context: {
enlace_behavior: "When enlace is enabled, Acai auto-generates URL slugs in /section/record-name/ format. The enlace field value is auto-formatted with slashes.",
seo_fields: "Enabling seo_metas adds meta title and description fields to the record editor. These are used in the <head> of the detail page.",
detail_template: "The general section template (save_general_section) defines what renders when a user visits a record's URL. Uses 'thisrecord' to access the current record's data."
enlace_behavior: "When the table has an 'enlace' field, Acai auto-generates URL slugs in /tableName/record-slug/ format. The value is auto-formatted with slashes.",
seo_fields: "SEO meta fields are just regular textfield/textbox fields named seo_title, seo_description, seo_keywords. For new tables you can skip this step by passing seoMetas=true to create_table.",
detail_template: "For any table with enlace, the record URL is rendered by the module 'custom-{tableName}' (convention — not configurable). The module accesses the current record via `thisrecord`. There is no '_detailPage' field."
},
rules: [
"Table names WITHOUT 'cms_' prefix",
"update_table_schema requires both tableName and the schema object",
"Enlace values are auto-formatted to /path/ format",
"SEO meta fields are only available after enabling seo_metas on the table"
"SEO fields are regular fields, not a special flag on the schema",
"The general section (detail template) is ALWAYS a module named 'custom-{tableName}' — never a separate page in 'apartados'.",
"There is no update_table_schema / _detailPage — routing is by convention on the module folder name."
],
warnings: [
"DO NOT enable enlace on a 'single' type table — single tables have only one record and usually don't need individual URLs",
"DO NOT forget to create a detail template after enabling enlace — without it, record URLs show blank pages"
"DO NOT enable enlace on a 'single' type table — single tables have one record and usually don't need individual URLs",
"DO NOT forget to create the 'custom-{tableName}' module after enabling enlace — without it, record URLs show blank pages",
"DO NOT configure '_detailPage' — it does not exist."
],
resources: []
};

View File

@@ -0,0 +1,52 @@
import { pythonPost } from "../helpers/pythonServerClient.js";
import { getCurrentProjectInfo } from "../files/helpers.js";
/**
* Llama a un endpoint /api/schema/* del server Python.
*
* Todas las schema-tools comparten el mismo contrato:
* - Resolver projectSlug desde la sesion actual.
* - POST al endpoint con { project, ...body }.
* - Mapear respuesta a formato MCP conservando warnings/schema/etc.
*
* No validamos payloads aqui: la responsabilidad es del caller (zod) y
* del backend Python (validaciones fuertes + proxy al PHP).
*
* @param {string} endpoint - Path relativo, ej: "/api/schema/create-table"
* @param {object} body - Campos especificos de la tool (sin `project`)
* @returns {Promise<{mcp: object}>} Respuesta lista para devolver desde la tool
*/
export async function callSchemaEndpoint(endpoint, body) {
const { projectSlug } = getCurrentProjectInfo();
const result = await pythonPost(endpoint, { project: projectSlug, ...body });
// success=false -> error con los campos utiles del backend (error,
// warnings, recordCount, dataCount, ...) preservados para el LLM.
if (!result || result.success === false) {
const { success: _s, ...rest } = result || {};
return {
mcp: {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: (result && result.error) || "Schema endpoint returned no success",
...rest,
}, null, 2),
}],
isError: true,
},
};
}
// success=true -> devolvemos el payload completo (incluye warnings,
// schema, recordCount, etc. cuando el backend los adjunta).
return {
mcp: {
content: [{
type: "text",
text: JSON.stringify(result, null, 2),
}],
},
};
}

View File

@@ -1,99 +1,51 @@
import { z } from "zod";
import { withAuth, getSessionCredentials } from "../../auth/index.js";
import { SAAS_URL } from "../../config/index.js";
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
import { AcaiHttpClient, FormParamsBuilder } from "../helpers/acaiHttpClient.js";
import { withAuth } from "../../auth/index.js";
import { withAuthParams } from "../helpers/authSchema.js";
import { handleToolError } from "../helpers/errorHandler.js";
import { callSchemaEndpoint } from "./_schemaEndpoint.js";
// Tool: create_table
// Crea una tabla nueva delegando en /api/schema/create-table. Enviamos solo
// "intencion" (menuType + flags) — el server Python construye el schemaPreset
// por defecto. Los tableNames viajan SIN el prefijo `cms_`; la PK siempre es `num`.
export function registerCreateTableTool(server) {
server.tool(
"create_table",
"Create a new database table/schema in the system. This creates the table structure with basic configuration. After creation, you can use update_table_schema to add custom fields and modify the schema. Table types: 'multi' (multiple records like news, contacts), 'single' (single record like homepage), 'category' (category menu), 'separador' (menu separator/container). Table names are WITHOUT the 'cms_' prefix.",
`Create a new database table/module for the current Acai project.
Menu types:
- 'multi': regular table with many records (news, products, contacts...)
- 'single': single-record page (homepage, about us...)
- 'category': category container — groups other tables under a menu node
- 'separador': visual separator in the admin menu
Parameters:
- tableName: technical name, lowercase + underscores, WITHOUT 'cms_' prefix. Primary key is always 'num'.
- menuName: display name in the admin sidebar.
- enlace: REQUIRED. Whether the table participates in public URLs (generates the 'enlace' field + slug). This is an architectural decision — ALWAYS ask the user before calling this tool.
- seoMetas: adds SEO meta fields (title, description, og:image). Default false.
- menuOrder: optional integer for sidebar order. Backend assigns one if omitted.`,
withAuthParams({
menuName: z.string().describe("Display name for the menu (e.g., 'Noticias', 'Productos')"),
tableName: z.string().describe("Technical table name, lowercase with underscores (e.g., 'noticias', 'productos'). Will be auto-generated from menuName if not provided."),
type: z.enum(["multi", "single", "category", "separador"]).describe("Table type: 'multi' for multiple records, 'single' for single record, 'category' for category menu, 'separador' for menu separator"),
enlace: z.boolean().describe("Whether this table should include the 'enlace' field (true = generates general section URLs, false = no enlace). Ask the user before running this tool."),
seo_metas: z.boolean().optional().describe("Whether this table has SEO meta fields. Default: false"),
menuOrder: z.number().optional().describe("Order in the menu. If not provided, will be added at the end."),
tableName: z.string().describe("Technical table name, lowercase + underscores, without 'cms_' prefix"),
menuName: z.string().describe("Display name shown in the admin sidebar"),
menuType: z.enum(["multi", "single", "category", "separador"]).describe("'multi' | 'single' | 'category' | 'separador'"),
enlace: z.boolean().describe("Whether the table has public URLs (generates 'enlace' field). REQUIRED — ask the user first."),
seoMetas: z.boolean().optional().describe("Include SEO meta fields. Default false."),
menuOrder: z.number().int().optional().describe("Order in the admin sidebar. Backend picks one if omitted."),
}),
{ readOnlyHint: false, destructiveHint: false },
withAuth(async ({ menuName, tableName, type, enlace, seo_metas = false, menuOrder }, extra) => {
withAuth(async ({ tableName, menuName, menuType, enlace, seoMetas, menuOrder }, _extra) => {
try {
// Validate required parameters
const validationError = validateRequired(
{ menuName, tableName, type, enlace },
['menuName', 'tableName', 'type', 'enlace'],
'create_table'
);
if (validationError) return validationError;
const body = { tableName, menuName, menuType, enlace };
if (typeof seoMetas === "boolean") body.seoMetas = seoMetas;
if (typeof menuOrder === "number") body.menuOrder = menuOrder;
if (typeof enlace !== "boolean") {
return {
content: [{ type: "text", text: "Error: 'enlace' must be explicitly set to true or false before calling this tool." }],
isError: true,
};
}
// If menuOrder not provided, get max order from existing tables
let order = menuOrder;
if (!order) {
try {
const credentials = await getSessionCredentials(extra.sessionId);
const tablesResponse = await AcaiHttpClient.saasPostRequest(
{
action: "getSchemaTables",
type: "acai"
},
credentials.token
);
if (tablesResponse.data.result && tablesResponse.data.data) {
const orders = tablesResponse.data.data.map(t => t.menuOrder || 0);
order = Math.max(...orders, 0) + 1;
} else {
order = 1;
}
} catch (e) {
order = 1;
}
}
// Create table via Acai CMS admin using centralized HTTP client
const params = FormParamsBuilder.buildTableCreateParams(menuName, tableName, type, enlace, seo_metas, order);
const credentials = await getSessionCredentials(extra.sessionId);
const createResponse = await AcaiHttpClient.postAdminForm(
credentials.website,
params,
credentials.token
);
// Check for API errors
const apiError = handleApiResponse(createResponse.data, 'create_table');
if (apiError) return apiError;
// Log response for debugging (stderr to avoid corrupting MCP stream)
console.error("CMS Response:", createResponse.data);
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
message: "Table created successfully",
tableName: tableName,
menuName: menuName,
type: type,
menuOrder: order,
note: "Table created. You can now use get_table_schema to view it or update_table_schema to add custom fields."
}, null, 2)
}],
};
const { mcp } = await callSchemaEndpoint("/api/schema/create-table", body);
return mcp;
} catch (error) {
return handleToolError(error, 'create_table', { menuName, tableName, type });
return handleToolError(error, "create_table", { tableName, menuType });
}
})
);
}

View File

@@ -0,0 +1,55 @@
import { z } from "zod";
import { withAuth } from "../../auth/index.js";
import { withAuthParams } from "../helpers/authSchema.js";
import { handleToolError } from "../helpers/errorHandler.js";
import { callSchemaEndpoint } from "./_schemaEndpoint.js";
// Tool: create_field
// Crea un nuevo campo en una tabla existente. Backend aplica los defaults
// segun `type` y permite overrides via `initialProps`.
const FIELD_TYPES = [
"textfield", "textbox", "wysiwyg", "date", "list",
"checkbox", "upload", "multitext", "codigo", "separator",
];
export function registerCreateFieldTool(server) {
server.tool(
"create_field",
`Add a new field to an existing table.
Field types:
- textfield: single-line text
- textbox: multi-line plain text
- wysiwyg: rich text editor
- codigo: code editor (HTML/JS/CSS snippet)
- date: date/datetime picker
- list: select/radio/checkboxes (needs listType + optionsType in initialProps)
- checkbox: boolean
- upload: file upload (images/docs)
- multitext: repeater of text entries
- separator: visual separator in the form (no data column)
'initialProps' is optional; use it to override defaults (e.g. {isRequired:1, maxLength:100}).
Table names WITHOUT 'cms_' prefix. Primary key is always 'num'.`,
withAuthParams({
tableName: z.string().describe("Table name without 'cms_' prefix"),
fieldName: z.string().describe("New field name (SQL-safe identifier)"),
label: z.string().describe("Human-readable label shown in the admin form"),
type: z.enum(FIELD_TYPES).describe("Field type"),
initialProps: z.object({}).passthrough().optional().describe("Optional overrides for the default field config"),
}),
{ readOnlyHint: false, destructiveHint: false },
withAuth(async ({ tableName, fieldName, label, type, initialProps }, _extra) => {
try {
const body = { tableName, fieldName, label, type };
if (initialProps && typeof initialProps === "object") body.initialProps = initialProps;
const { mcp } = await callSchemaEndpoint("/api/schema/create-field", body);
return mcp;
} catch (error) {
return handleToolError(error, "create_field", { tableName, fieldName, type });
}
})
);
}

View File

@@ -1,52 +1,49 @@
import { z } from "zod";
import { withAuth, getSessionCredentials } from "../../auth/index.js";
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
import { AcaiHttpClient, FormParamsBuilder } from "../helpers/acaiHttpClient.js";
import { withAuth } from "../../auth/index.js";
import { withAuthParams } from "../helpers/authSchema.js";
import { handleToolError } from "../helpers/errorHandler.js";
import { callSchemaEndpoint } from "./_schemaEndpoint.js";
// Tool: delete_table
// Borra el schema (ini.php) de una tabla. Si dropData=true tambien hace DROP TABLE
// en MySQL — destruye los datos de forma IRREVERSIBLE. Si la tabla tiene registros
// y dropData=false el backend devuelve error con recordCount (intencional: no se
// debe borrar el schema dejando datos huerfanos en MySQL sin confirmacion explicita).
export function registerDeleteTableTool(server) {
server.tool(
"delete_table",
"⚠️ DANGEROUS: Delete a database table/module entirely. This removes the table definition and all its data. This operation is IRREVERSIBLE. Table names are WITHOUT the 'cms_' prefix.",
`Delete a table from the project. IRREVERSIBLE when dropData=true.
Behaviour:
- dropData=false (default): deletes the schema (.ini.php). If the MySQL table
has records, the backend refuses and returns recordCount so you can warn
the user.
- dropData=true: DROP TABLE in MySQL + delete schema. Data is permanently
destroyed.
- dryRun=true: does not delete anything, only reports recordCount. Use as a
pre-flight check before asking the user for confirmation.
Table names are WITHOUT the 'cms_' prefix.`,
withAuthParams({
tableName: z.string().describe("Name of the table/module to delete (without 'cms_' prefix, e.g., 'equipo')"),
tableName: z.string().describe("Table name without 'cms_' prefix"),
dropData: z.boolean().optional().describe("If true, DROP the MySQL table. Default false."),
dryRun: z.boolean().optional().describe("If true, only report recordCount without deleting. Default false."),
}),
{ readOnlyHint: false, destructiveHint: true },
withAuth(async ({ tableName }, extra) => {
withAuth(async ({ tableName, dropData, dryRun }, _extra) => {
try {
// Validate required parameters
const validationError = validateRequired({ tableName }, ['tableName'], 'delete_table');
if (validationError) return validationError;
// Build delete table parameters using centralized builder
const params = FormParamsBuilder.buildTableDeleteParams(tableName);
const credentials = await getSessionCredentials(extra.sessionId);
// Delete table via Acai CMS admin using centralized HTTP client
const response = await AcaiHttpClient.postAdminForm(
credentials.website,
params,
credentials.token
);
// Check for API errors
const apiError = handleApiResponse(response.data, 'delete_table');
if (apiError) return apiError;
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
message: `Table '${tableName}' deleted successfully`
}, null, 2)
}],
const body = {
tableName,
confirm: true,
dropData: dropData === true,
dryRun: dryRun === true,
};
const { mcp } = await callSchemaEndpoint("/api/schema/delete-table", body);
return mcp;
} catch (error) {
return handleToolError(error, 'delete_table', { tableName });
return handleToolError(error, "delete_table", { tableName, dropData: dropData === true, dryRun: dryRun === true });
}
})
);
}

View File

@@ -0,0 +1,45 @@
import { z } from "zod";
import { withAuth } from "../../auth/index.js";
import { withAuthParams } from "../helpers/authSchema.js";
import { handleToolError } from "../helpers/errorHandler.js";
import { callSchemaEndpoint } from "./_schemaEndpoint.js";
// Tool: delete_field
// Borra un campo del schema y opcionalmente su columna MySQL (dropColumn=true).
// Si dropColumn=false y la columna tiene datos, el backend devuelve error con
// dataCount para permitir confirmacion explicita.
export function registerDeleteFieldTool(server) {
server.tool(
"delete_field",
`Delete a field from a table.
- dropColumn=false (default): removes only the schema entry. If the MySQL
column has data, backend refuses and returns dataCount so you can warn
the user.
- dropColumn=true: ALTER TABLE DROP COLUMN. Data in that column is lost
permanently.
Table names WITHOUT 'cms_' prefix.`,
withAuthParams({
tableName: z.string().describe("Table name without 'cms_' prefix"),
fieldName: z.string().describe("Field name to delete"),
dropColumn: z.boolean().optional().describe("If true, DROP COLUMN in MySQL. Default false."),
}),
{ readOnlyHint: false, destructiveHint: true },
withAuth(async ({ tableName, fieldName, dropColumn }, _extra) => {
try {
const body = {
tableName,
fieldName,
confirm: true,
dropColumn: dropColumn === true,
};
const { mcp } = await callSchemaEndpoint("/api/schema/delete-field", body);
return mcp;
} catch (error) {
return handleToolError(error, "delete_field", { tableName, fieldName, dropColumn: dropColumn === true });
}
})
);
}

View File

@@ -1,207 +0,0 @@
import { z } from "zod";
import fsPromises from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { withAuth, getSessionCredentials } from "../../auth/index.js";
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
import { AcaiHttpClient, FormParamsBuilder } from "../helpers/acaiHttpClient.js";
import { withAuthParams } from "../helpers/authSchema.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export function registerEditTableFieldTool(server) {
server.tool(
"edit_table_field",
`Create or edit fields in a database table. Use this for ALL field operations — do NOT use update_table_schema.
Tables WITHOUT 'cms_' prefix. Field types: textfield, textbox, wysiwyg, codigo, checkbox, date, list, multitext, upload, separator, none.
For 'list': set optionsType to 'text', 'table', or 'query' with corresponding option params.
TIP: Don't set isRequired=true on upload fields.`,
withAuthParams({
tableName: z.string().describe("Name of the table (without 'cms_' prefix)"),
fields: z.array(z.object({
fieldname: z.string().describe("Current field name (for editing) or new field name (for creating)"),
newFieldname: z.string().optional().describe("New field name if renaming the field. Leave empty if not renaming."),
label: z.string().optional().describe("Field label shown in the UI"),
type: z.enum(["textfield", "textbox", "wysiwyg", "codigo", "checkbox", "date", "list", "multitext", "upload", "separator", "none"]).optional().describe("Field type"),
order: z.number().optional().describe("Display order in the form"),
defaultValue: z.string().optional().describe("Default value for the field"),
description: z.string().optional().describe("Field description/help text"),
isRequired: z.union([z.number(), z.boolean()]).optional().describe("Whether field is required (0/1 or false/true)"),
isUnique: z.union([z.number(), z.boolean()]).optional().describe("Whether field must be unique (0/1 or false/true)"),
// List field options
listType: z.enum(["pulldown", "radios", "pulldownMulti", "checkboxes"]).optional().describe("For 'list' type: how to display options"),
optionsType: z.enum(["text", "table", "query"]).optional().describe("For 'list' type: source of options"),
optionsText: z.string().optional().describe("For optionsType='text': newline-separated options (use 'value|Label' format)"),
optionsTablename: z.string().optional().describe("For optionsType='table': source table name"),
optionsValueField: z.string().optional().describe("For optionsType='table': field to use as value"),
optionsLabelField: z.string().optional().describe("For optionsType='table': field to display as label"),
optionsQuery: z.string().optional().describe("For optionsType='query': SQL query to get options"),
// Validation
minLength: z.number().optional().describe("Minimum length for text fields"),
maxLength: z.number().optional().describe("Maximum length for text fields"),
// Upload field options
allowedExtensions: z.string().optional().describe("For 'upload' type: comma-separated file extensions"),
maxUploads: z.number().optional().describe("For 'upload' type: maximum number of files"),
createThumbnails: z.union([z.number(), z.boolean()]).optional().describe("For 'upload' type: create thumbnails (0/1)"),
maxThumbnailWidth: z.number().optional().describe("For 'upload' type: thumbnail width"),
maxThumbnailHeight: z.number().optional().describe("For 'upload' type: thumbnail height"),
// Advanced options
isSystemField: z.union([z.number(), z.boolean()]).optional().describe("System field, cannot be edited by users (0/1)"),
adminOnly: z.union([z.number(), z.boolean()]).optional().describe("Only admin can modify (0/1)"),
fieldWidth: z.number().optional().describe("Field width in pixels"),
fieldHeight: z.number().optional().describe("Field height in pixels (for textbox, wysiwyg, codigo)"),
}).passthrough()).describe("Array of field configurations. Each field can include any properties from fieldData.json."),
}),
{ readOnlyHint: false, destructiveHint: false },
withAuth(async ({ tableName, fields }, extra) => {
const startTime = Date.now();
console.error(`[Tool] edit_table_field - START: tableName=${tableName}, fieldCount=${fields.length}, sessionId=${extra.sessionId}`);
try {
// Validate required parameters
const validationError = validateRequired(
{ tableName, fields },
['tableName', 'fields'],
'edit_table_field'
);
if (validationError) {
console.error(`[Tool] edit_table_field - VALIDATION ERROR: ${validationError.content[0].text}`);
return validationError;
}
// Load fieldData.json as template (from server root directory)
const fieldDataPath = path.join(__dirname, '..', '..', 'fieldData.json');
let fieldDataTemplate;
try {
const fieldDataRaw = await fsPromises.readFile(fieldDataPath, 'utf-8');
fieldDataTemplate = JSON.parse(fieldDataRaw);
} catch (error) {
return {
content: [{ type: "text", text: `Error loading fieldData.json template: ${error.message}. Make sure fieldData.json exists in the server directory.` }],
isError: true,
};
}
// Build multipleFields array
const multipleFields = fields.map(fieldConfig => {
const { fieldname, newFieldname, ...restConfig } = fieldConfig;
// Build the complete field data by merging template with provided config
const fieldData = {
...fieldDataTemplate,
...restConfig,
fieldname: fieldname,
newFieldname: newFieldname || fieldname,
};
// Convert boolean values to 0/1 for compatibility
Object.keys(fieldData).forEach(key => {
if (typeof fieldData[key] === 'boolean') {
fieldData[key] = fieldData[key] ? 1 : 0;
}
});
return fieldData;
});
// Create URLSearchParams with root parameters using centralized builder
const params = FormParamsBuilder.buildFieldEditParams(`${tableName}`, multipleFields);
const credentials = await getSessionCredentials(extra.sessionId);
// Send to Acai CMS admin.php using centralized HTTP client
const response = await AcaiHttpClient.postAdminForm(
credentials.website,
params,
credentials.token
);
// Check for error response
if (response.data && typeof response.data === 'string' && response.data.trim().length > 0) {
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
message: "Field operation completed with message",
serverResponse: response.data,
tableName: tableName,
fieldsCount: fields.length
}, null, 2)
}],
};
}
const elapsedTime = Date.now() - startTime;
console.error(`[Tool] edit_table_field - SUCCESS: completed in ${elapsedTime}ms, fieldsCount=${fields.length}`);
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
message: fields.length === 1
? `Field '${fields[0].fieldname}' processed successfully`
: `${fields.length} fields processed successfully`,
tableName: tableName,
fieldsProcessed: fields.map(f => f.newFieldname || f.fieldname),
debugResponse: response.data
}, null, 2)
}],
};
} catch (error) {
const elapsedTime = Date.now() - startTime;
console.error(`[Tool] edit_table_field - ERROR after ${elapsedTime}ms: ${error.message}`);
return handleToolError(error, 'edit_table_field', { tableName, fieldCount: fields.length });
}
})
);
}
export function registerDeleteTableFieldTool(server) {
server.tool(
"delete_table_field",
"Delete a field from a database table structure. WARNING: This will delete all data in this column. Table names are WITHOUT the 'cms_' prefix.",
withAuthParams({
tableName: z.string().describe("Name of the table (without 'cms_' prefix)"),
fieldname: z.string().describe("Name of the field to delete"),
}),
{ readOnlyHint: false, destructiveHint: true },
withAuth(async ({ tableName, fieldname }, extra) => {
try {
// Build delete field parameters using centralized builder
const params = FormParamsBuilder.buildFieldDeleteParams(`cms_${tableName}`, fieldname);
const credentials = await getSessionCredentials(extra.sessionId);
// Delete field via Acai CMS admin using centralized HTTP client
const response = await AcaiHttpClient.postAdminForm(
credentials.website,
params,
credentials.token
);
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
message: `Field '${fieldname}' deleted from table '${tableName}'`,
tableName: tableName
}, null, 2)
}],
};
} catch (error) {
return handleToolError(error, 'delete_table_field', { tableName, fieldname });
}
})
);
}

View File

@@ -1,7 +1,25 @@
import { registerListTablesTool } from './list.js';
import { registerGetTableSchemaTool } from './schema.js';
import { registerCreateTableTool } from './create.js';
import { registerUpdateTableMetadataTool } from './updateMetadata.js';
import { registerDeleteTableTool } from './delete.js';
import { registerReorderTablesTool } from './reorderTables.js';
import { registerCreateFieldTool } from './createField.js';
import { registerUpdateFieldTool } from './updateField.js';
import { registerDeleteFieldTool } from './deleteField.js';
import { registerReorderFieldsTool } from './reorderFields.js';
import { registerRegenerateEnlacesTool } from './regenerateEnlaces.js';
export function registerTableTools(server) {
registerListTablesTool(server);
registerGetTableSchemaTool(server);
registerCreateTableTool(server);
registerUpdateTableMetadataTool(server);
registerDeleteTableTool(server);
registerReorderTablesTool(server);
registerCreateFieldTool(server);
registerUpdateFieldTool(server);
registerDeleteFieldTool(server);
registerReorderFieldsTool(server);
registerRegenerateEnlacesTool(server);
}

View File

@@ -0,0 +1,45 @@
import { z } from "zod";
import { withAuth } from "../../auth/index.js";
import { withAuthParams } from "../helpers/authSchema.js";
import { handleToolError } from "../helpers/errorHandler.js";
import { callSchemaEndpoint } from "./_schemaEndpoint.js";
// Tool: regenerate_enlaces
// Recalcula el campo `enlace` (slug) de todos los registros de una tabla.
// Cambia URLs publicas — destructivo para SEO / links externos. Si
// generateAlias=true se crean entradas en `alias_urls` para que los links
// viejos sigan funcionando.
export function registerRegenerateEnlacesTool(server) {
server.tool(
"regenerate_enlaces",
`Regenerate the 'enlace' (URL slug) of every record in a table.
Changes PUBLIC URLs — anything linking to the old slugs (external sites, saved
bookmarks, search engines) will 404 unless you opt into alias redirects.
- generateAlias=false (default): only updates the 'enlace' column. Old URLs
return 404.
- generateAlias=true: also writes entries into 'alias_urls' so old URLs
redirect to the new ones. Safer choice when the table is already public.
Table names WITHOUT 'cms_' prefix.`,
withAuthParams({
tableName: z.string().describe("Table name without 'cms_' prefix"),
generateAlias: z.boolean().optional().describe("If true, write redirects into alias_urls. Default false."),
}),
{ readOnlyHint: false, destructiveHint: true },
withAuth(async ({ tableName, generateAlias }, _extra) => {
try {
const body = {
tableName,
generateAlias: generateAlias === true,
};
const { mcp } = await callSchemaEndpoint("/api/schema/regenerate-enlaces", body);
return mcp;
} catch (error) {
return handleToolError(error, "regenerate_enlaces", { tableName, generateAlias: generateAlias === true });
}
})
);
}

View File

@@ -0,0 +1,30 @@
import { z } from "zod";
import { withAuth } from "../../auth/index.js";
import { withAuthParams } from "../helpers/authSchema.js";
import { handleToolError } from "../helpers/errorHandler.js";
import { callSchemaEndpoint } from "./_schemaEndpoint.js";
// Tool: reorder_fields
// Reasigna el orden de los campos editables de una tabla. Los campos de
// sistema (num, creationDate, etc.) se ignoran; solo afecta al orden visual
// en el formulario del admin.
export function registerReorderFieldsTool(server) {
server.tool(
"reorder_fields",
`Reorder fields inside a table's admin form. Pass the full ordered list of fieldNames; system fields (num, creationDate, etc.) are ignored by the backend. Data is untouched. Table names WITHOUT 'cms_' prefix.`,
withAuthParams({
tableName: z.string().describe("Table name without 'cms_' prefix"),
order: z.array(z.string().min(1)).min(1).describe("Ordered list of fieldNames"),
}),
{ readOnlyHint: false, destructiveHint: false },
withAuth(async ({ tableName, order }, _extra) => {
try {
const { mcp } = await callSchemaEndpoint("/api/schema/reorder-fields", { tableName, order });
return mcp;
} catch (error) {
return handleToolError(error, "reorder_fields", { tableName, count: Array.isArray(order) ? order.length : 0 });
}
})
);
}

View File

@@ -0,0 +1,28 @@
import { z } from "zod";
import { withAuth } from "../../auth/index.js";
import { withAuthParams } from "../helpers/authSchema.js";
import { handleToolError } from "../helpers/errorHandler.js";
import { callSchemaEndpoint } from "./_schemaEndpoint.js";
// Tool: reorder_tables
// Reasigna el menuOrder de cada tabla segun el orden de la lista recibida.
// Idempotente. No afecta datos, solo el orden de presentacion en el admin.
export function registerReorderTablesTool(server) {
server.tool(
"reorder_tables",
`Reorder tables in the admin sidebar. Pass the full ordered list of tableNames; the backend reassigns menuOrder sequentially. Only the sidebar order changes — data and schemas are untouched. Table names WITHOUT 'cms_' prefix.`,
withAuthParams({
order: z.array(z.string().min(1)).min(1).describe("Ordered list of tableNames (the new sidebar order)"),
}),
{ readOnlyHint: false, destructiveHint: false },
withAuth(async ({ order }, _extra) => {
try {
const { mcp } = await callSchemaEndpoint("/api/schema/reorder-tables", { order });
return mcp;
} catch (error) {
return handleToolError(error, "reorder_tables", { count: Array.isArray(order) ? order.length : 0 });
}
})
);
}

View File

@@ -0,0 +1,52 @@
import { z } from "zod";
import { withAuth } from "../../auth/index.js";
import { withAuthParams } from "../helpers/authSchema.js";
import { handleToolError } from "../helpers/errorHandler.js";
import { callSchemaEndpoint } from "./_schemaEndpoint.js";
// Tool: update_field
// Actualiza props de un campo. Puede renombrar la columna MySQL (newFieldName).
// Cambios de 'type' pueden truncar datos; el backend devuelve warnings que
// propagamos intactos — son info critica para el LLM.
export function registerUpdateFieldTool(server) {
server.tool(
"update_field",
`Update properties of an existing field.
Common 'props' keys (not exhaustive; passthrough accepted):
label, type, description, isRequired, isUnique, defaultValue,
minLength, maxLength, listType, optionsType, optionsText,
optionsTablename, optionsValueField, optionsLabelField, optionsQuery,
filterField, allowedExtensions, maxUploads, createThumbnails,
maxThumbnailWidth, maxThumbnailHeight, fieldWidth, fieldHeight,
adminOnly, charsetRule, charset, tipoTags, tipoAtributo.
Destructive cases:
- 'newFieldName' renames the MySQL column (data preserved, but any hardcoded
reference breaks).
- Changing 'type' may coerce/truncate existing data (e.g. wysiwyg -> textfield
drops HTML). The backend returns 'warnings' in the response — surface them
to the user.
Table names WITHOUT 'cms_' prefix.`,
withAuthParams({
tableName: z.string().describe("Table name without 'cms_' prefix"),
fieldName: z.string().describe("Current field name"),
newFieldName: z.string().optional().describe("If set, rename the column. Data is preserved but hardcoded references break."),
props: z.object({}).passthrough().describe("Partial props object with the keys to update"),
}),
{ readOnlyHint: false, destructiveHint: true },
withAuth(async ({ tableName, fieldName, newFieldName, props }, _extra) => {
try {
const body = { tableName, fieldName, props };
if (newFieldName) body.newFieldName = newFieldName;
const { mcp } = await callSchemaEndpoint("/api/schema/update-field", body);
return mcp;
} catch (error) {
return handleToolError(error, "update_field", { tableName, fieldName, newFieldName });
}
})
);
}

View File

@@ -0,0 +1,44 @@
import { z } from "zod";
import { withAuth } from "../../auth/index.js";
import { withAuthParams } from "../helpers/authSchema.js";
import { handleToolError } from "../helpers/errorHandler.js";
import { callSchemaEndpoint } from "./_schemaEndpoint.js";
// Tool: update_table_metadata
// Edita el bloque [meta] de un schema (menuName, menuType, listPage*, etc.) y
// opcionalmente renombra la tabla en MySQL. Delegamos en /api/schema/update-table-meta.
export function registerUpdateTableMetadataTool(server) {
server.tool(
"update_table_metadata",
`Update the metadata block of a table (the [meta] section of its schema).
Accepted keys in 'meta' include:
menuName, menuDesc, menuType, menuOrder, menuDisplay, menuHidden,
controller, breadcrumbField, breadcrumbByLink, breadcrumbParentNum,
listPageFields (csv), listPageOrder, listPageSearchFields.
If 'newTableName' is provided the underlying MySQL table is RENAMED. This is
destructive because any hardcoded references (custom controllers, module SQL,
embedded queries in content) WILL break — audit the codebase before renaming.
Table names are WITHOUT the 'cms_' prefix.`,
withAuthParams({
tableName: z.string().describe("Current table name, without 'cms_' prefix"),
meta: z.object({}).passthrough().describe("Partial meta object with the keys you want to change"),
newTableName: z.string().optional().describe("If set, rename the table. Breaks hardcoded references — confirm with user first."),
}),
{ readOnlyHint: false, destructiveHint: true },
withAuth(async ({ tableName, meta, newTableName }, _extra) => {
try {
const body = { tableName, meta };
if (newTableName) body.newTableName = newTableName;
const { mcp } = await callSchemaEndpoint("/api/schema/update-table-meta", body);
return mcp;
} catch (error) {
return handleToolError(error, "update_table_metadata", { tableName, newTableName });
}
})
);
}