From ca39cd2ccdb7fba4d8929d872b95566740425002 Mon Sep 17 00:00:00 2001 From: Jordan Diaz Date: Sun, 12 Apr 2026 10:16:52 +0000 Subject: [PATCH] tablas y delete module --- mcp-server/tools/modules/checkUsage.js | 18 ++- mcp-server/tools/modules/delete.js | 53 ++++++++ mcp-server/tools/modules/index.js | 2 + mcp-server/tools/tables/index.js | 21 +-- mcp-server/tools/tables/iniParser.js | 65 +++++++++ mcp-server/tools/tables/list.js | 76 ++++------- mcp-server/tools/tables/schema.js | 179 +++++-------------------- 7 files changed, 191 insertions(+), 223 deletions(-) create mode 100644 mcp-server/tools/modules/delete.js create mode 100644 mcp-server/tools/tables/iniParser.js diff --git a/mcp-server/tools/modules/checkUsage.js b/mcp-server/tools/modules/checkUsage.js index 06c96c4..f5fda06 100644 --- a/mcp-server/tools/modules/checkUsage.js +++ b/mcp-server/tools/modules/checkUsage.js @@ -38,19 +38,23 @@ export function registerCheckModuleUsageTool(server) { const apiError = handleApiResponse(response.data, 'check_module_usage'); if (apiError) return apiError; - // Extract usage information - const usageData = response.data.data || response.data; + // El PHP devuelve { result, success, message }. Si el modulo NO esta + // en uso, message = "No encuentro el módulo en ninguna sección". + // Si esta en uso, message contiene HTML con las tablas/paginas. + const msg = (response.data?.message || ""); + const inUse = !!msg && !msg.includes("No encuentro"); return { content: [{ type: "text", text: JSON.stringify({ success: true, moduleId: id, - usage: usageData, - canDelete: !usageData || Object.keys(usageData).length === 0, - message: Object.keys(usageData || {}).length === 0 - ? "Module is not used anywhere - safe to delete" - : `Module is used in ${Object.keys(usageData || {}).length} location(s)` + inUse, + canDelete: !inUse, + message: inUse + ? "Module is in use — deletion denied. Inform the user which pages use it and stop. Do NOT attempt to remove it from pages." + : "Module is not used anywhere — safe to delete", + rawMessage: msg, }, null, 2) }], }; diff --git a/mcp-server/tools/modules/delete.js b/mcp-server/tools/modules/delete.js new file mode 100644 index 0000000..0ab1a0d --- /dev/null +++ b/mcp-server/tools/modules/delete.js @@ -0,0 +1,53 @@ +import { z } from "zod"; +import { withAuth, getSessionCredentials, getApiClient } from "../../auth/index.js"; +import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js"; +import { withAuthParams } from "../helpers/authSchema.js"; +import { AcaiHttpClient } from "../helpers/acaiHttpClient.js"; + +export function registerDeleteModuleTool(server) { + server.tool( + "delete_module", + "Elimina un módulo del proyecto. Borra la carpeta completa del módulo (template/estandar/modulos/{moduleId}/). OBLIGATORIO: llama a check_module_usage ANTES. Si el módulo está en uso (inUse=true), DENIEGA el borrado e informa al usuario de las páginas donde se usa. NO intentes quitar el módulo de las páginas por tu cuenta — solo el usuario puede decidir eso.", + withAuthParams({ + moduleId: z.string().describe("ID del módulo a eliminar (nombre de la carpeta)"), + }), + { readOnlyHint: false, destructiveHint: true }, + withAuth(async ({ moduleId }, extra) => { + try { + const validationError = validateRequired({ moduleId }, ['moduleId'], 'delete_module'); + if (validationError) return validationError; + + const credentials = await getSessionCredentials(extra.sessionId); + + const payload = { + action_ws: "deleteModule", + fileName: moduleId, + token: credentials.token, + tokenHash: credentials.tokenHash + }; + + const response = await AcaiHttpClient.postViewerFunctions( + await getApiClient(extra.sessionId), + payload + ); + + // Check for API errors (ej: módulo en uso) + const apiError = handleApiResponse(response.data, 'delete_module'); + if (apiError) return apiError; + + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: true, + moduleId, + message: `Módulo "${moduleId}" eliminado correctamente.` + }, null, 2) + }], + }; + } catch (error) { + return handleToolError(error, 'delete_module', { moduleId }); + } + }) + ); +} diff --git a/mcp-server/tools/modules/index.js b/mcp-server/tools/modules/index.js index 77a8067..b4323f1 100644 --- a/mcp-server/tools/modules/index.js +++ b/mcp-server/tools/modules/index.js @@ -1,6 +1,7 @@ import { registerCheckModuleTool } from './check.js'; import { registerCheckModuleUsageTool } from './checkUsage.js'; import { registerCompileModuleTool } from './compile.js'; +import { registerDeleteModuleTool } from './delete.js'; import { canEditCode } from '../helpers/roleCheck.js'; export function registerModuleTools(server) { @@ -8,5 +9,6 @@ export function registerModuleTools(server) { registerCheckModuleUsageTool(server); if (canEditCode()) { registerCompileModuleTool(server); + registerDeleteModuleTool(server); } } diff --git a/mcp-server/tools/tables/index.js b/mcp-server/tools/tables/index.js index 5aebdc6..60ce08a 100644 --- a/mcp-server/tools/tables/index.js +++ b/mcp-server/tools/tables/index.js @@ -1,20 +1,7 @@ -// TODO: adaptar create, delete, fields, list, schema para Docker local -// import { registerListTablesTool } from './list.js'; -// import { registerGetTableSchemaTool, registerUpdateTableSchemaTool } from './schema.js'; -// import { registerGetTableTemplatesTool } from './templates.js'; -// import { registerCreateTableTool } from './create.js'; -// import { registerDeleteTableTool } from './delete.js'; -// import { registerEditTableFieldTool, registerDeleteTableFieldTool } from './fields.js'; +import { registerListTablesTool } from './list.js'; +import { registerGetTableSchemaTool } from './schema.js'; export function registerTableTools(server) { - // registerListTablesTool(server); - // registerGetTableSchemaTool(server); - // registerUpdateTableSchemaTool(server); - // registerGetTableTemplatesTool(server); - // registerCreateTableTool(server); - // registerDeleteTableTool(server); - // registerEditTableFieldTool(server); - // registerDeleteTableFieldTool(server); + registerListTablesTool(server); + registerGetTableSchemaTool(server); } - - diff --git a/mcp-server/tools/tables/iniParser.js b/mcp-server/tools/tables/iniParser.js new file mode 100644 index 0000000..ead490f --- /dev/null +++ b/mcp-server/tools/tables/iniParser.js @@ -0,0 +1,65 @@ +const BOOL_FIELDS = new Set([ + 'isSystemField', 'isRequired', 'isAdmin', 'adminOnly', 'hidden', + '_detailPage', '_disableAdd', '_disableDelete' +]); +const INT_FIELDS = new Set(['order', 'menuOrder', 'maxRecords', 'maxUploads']); + +function stripPhpLine(ini) { + return ini.replace(/^<\?php.*?\?>\s*\n?/, ''); +} + +function coerceValue(key, value) { + if (BOOL_FIELDS.has(key)) return value === '1'; + if (INT_FIELDS.has(key)) { const n = parseInt(value, 10); return isNaN(n) ? 0 : n; } + return value; +} + +export function parseIniMeta(iniContent) { + const clean = stripPhpLine(iniContent); + const meta = {}; + for (const line of clean.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith(';') || trimmed.startsWith('#')) continue; + if (trimmed.startsWith('[')) break; + const m = trimmed.match(/^([^\s=]+)\s*=\s*(.*)$/); + if (m) { + const key = m[1]; + const value = m[2].trim().replace(/^"|"$/g, ''); + meta[key] = coerceValue(key, value); + } + } + return meta; +} + +export function parseIniSchema(iniContent) { + const clean = stripPhpLine(iniContent); + const result = {}; + const fields = {}; + let currentSection = null; + + for (const line of clean.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith(';') || trimmed.startsWith('#')) continue; + + const sectionMatch = trimmed.match(/^\[(.+)\]$/); + if (sectionMatch) { + currentSection = sectionMatch[1]; + fields[currentSection] = {}; + continue; + } + + const kvMatch = trimmed.match(/^([^\s=]+)\s*=\s*(.*)$/); + if (kvMatch) { + const key = kvMatch[1]; + const value = kvMatch[2].trim().replace(/^"|"$/g, ''); + if (currentSection) { + fields[currentSection][key] = coerceValue(key, value); + } else { + result[key] = coerceValue(key, value); + } + } + } + + result.fields = fields; + return result; +} diff --git a/mcp-server/tools/tables/list.js b/mcp-server/tools/tables/list.js index 0ac5215..5667ac1 100644 --- a/mcp-server/tools/tables/list.js +++ b/mcp-server/tools/tables/list.js @@ -1,65 +1,43 @@ import { z } from "zod"; -import { withAuth, getSessionCredentials } from "../../auth/index.js"; +import { withAuth, getSessionCredentials, getApiClient } from "../../auth/index.js"; import { handleToolError, handleApiResponse } from "../helpers/errorHandler.js"; import { AcaiHttpClient } from "../helpers/acaiHttpClient.js"; import { withAuthParams } from "../helpers/authSchema.js"; +import { parseIniMeta } from "./iniParser.js"; export function registerListTablesTool(server) { server.tool( "list_tables", - "List all database tables/schemas and General Sections (tables with 'enlace' field) in the system. Table names returned here are WITHOUT the 'cms_' prefix — use them as-is in all other tool calls. The primary key for all tables is 'num', never 'id'.", - withAuthParams({ - withoutEnlace: z.boolean().default(true).describe("If true, include all tables, not only the ones that are general sections with 'enlace' field"), - }), + "List all database tables in the project. Table names are WITHOUT 'cms_' prefix — use them as-is in all other tool calls. Primary key is 'num', never 'id'.", + withAuthParams({}), { readOnlyHint: true, destructiveHint: false }, - withAuth(async ({ withoutEnlace }, extra) => { + withAuth(async (params, extra) => { try { - console.error(`[list_tables] Tool called with sessionId: ${extra.sessionId}`); - console.error(`[list_tables] Getting credentials for session...`); - - const creds = await getSessionCredentials(extra.sessionId); - console.error(`[list_tables] Credentials: website=${creds.website}, hasToken=${!!creds.token}, profileName=${creds.profileName}`); - - if (!creds.token) { - console.error(`[list_tables] ERROR: No token found for session ${extra.sessionId}!`); - return { - content: [{ - type: "text", - text: JSON.stringify({ - error: "No authentication token found for this session. Please login first using login_client tool.", - sessionId: extra.sessionId, - profileName: creds.profileName - }, null, 2) - }], - isError: true - }; - } - - const response = await AcaiHttpClient.saasPostRequest( - { - action: 'getSchemaTables', - type: 'menu' - }, - creds.token + const credentials = await getSessionCredentials(extra.sessionId); + const payload = { + action_ws: "getTableSchemas", + token: credentials.token, + tokenHash: credentials.tokenHash, + }; + const response = await AcaiHttpClient.postViewerFunctions( + await getApiClient(extra.sessionId), + payload ); + const apiError = handleApiResponse(response.data, 'list_tables'); + if (apiError) return apiError; - if (!response.data.success) { + const schemas = response.data.schemas || {}; + const tables = Object.entries(schemas).map(([filename, iniContent]) => { + const tableName = filename.replace('.ini.php', ''); + const meta = parseIniMeta(iniContent); return { - content: [{ type: "text", text: "Error getting tables: " + JSON.stringify(response.data) }], - isError: true, + tableName, + menuName: meta.menuName || tableName, + menuType: meta.menuType || 'multi', + enlace: meta.enlace || null, + hasBuilder: iniContent.includes('[builder]'), }; - } - - // Filter tables based on withoutEnlace parameter - const tables = response.data.data.filter(schema => - withoutEnlace ? true : !!schema.enlace - ).map(table => ({ - name: table.menuName, - tableName: table.tableName, - order: table.menuOrder, - enlace: table.enlace, - hasBuilder: !!table.builder - })); + }); return { content: [{ type: "text", text: JSON.stringify(tables, null, 2) }], @@ -70,5 +48,3 @@ export function registerListTablesTool(server) { }) ); } - - diff --git a/mcp-server/tools/tables/schema.js b/mcp-server/tools/tables/schema.js index 0159f4c..f2cb06e 100644 --- a/mcp-server/tools/tables/schema.js +++ b/mcp-server/tools/tables/schema.js @@ -1,184 +1,65 @@ import { z } from "zod"; -import { withAuth, getApiClient, getSessionCredentials, getCommonParams } from "../../auth/index.js"; -import { normalizeSchemaForSave, mergeTableSchemas } from "../../utils/fieldHelpers.js"; +import { withAuth, getSessionCredentials, getApiClient } from "../../auth/index.js"; import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js"; import { AcaiHttpClient } from "../helpers/acaiHttpClient.js"; import { withAuthParams } from "../helpers/authSchema.js"; +import { parseIniSchema } from "./iniParser.js"; export function registerGetTableSchemaTool(server) { server.tool( "get_table_schema", - "Get the schema of a database table. Tables WITHOUT 'cms_' prefix. Primary key is 'num', NEVER 'id'. Use minimal=true for just field names + types (saves tokens).", + "Get the full schema of a database table, including all fields and their types/metadata. Table names WITHOUT 'cms_' prefix. Primary key is 'num', never 'id'. Use minimal=true for compact output (saves tokens).", withAuthParams({ - tableName: z.string().describe("Name of the table to get schema for (without 'cms_' prefix)"), - minimal: z.boolean().optional().describe("If true, returns only field names and types (compact). Default: false (full schema with all metadata)."), + tableName: z.string().describe("Table name without cms_ prefix"), + minimal: z.boolean().optional().describe("If true, returns only field names + types + labels"), }), { readOnlyHint: true, destructiveHint: false }, withAuth(async ({ tableName, minimal }, extra) => { try { - // Validate required parameters const validationError = validateRequired({ tableName }, ['tableName'], 'get_table_schema'); if (validationError) return validationError; const credentials = await getSessionCredentials(extra.sessionId); - const response = await AcaiHttpClient.saasPostRequest( - { - id: tableName - }, - credentials.token + const payload = { + action_ws: "getTableSchemas", + tableName, + token: credentials.token, + tokenHash: credentials.tokenHash, + }; + const response = await AcaiHttpClient.postViewerFunctions( + await getApiClient(extra.sessionId), + payload ); + const apiError = handleApiResponse(response.data, 'get_table_schema'); + if (apiError) return apiError; - if (!response.data.success) { - return { - content: [{ type: "text", text: "Error getting schema: " + JSON.stringify(response.data) }], - isError: true, - }; - } - - // Find the specific table - const table = response.data.data; - - if (!table) { + const schemas = response.data.schemas || {}; + const filename = tableName + '.ini.php'; + const iniContent = schemas[filename]; + if (!iniContent) { return { content: [{ type: "text", text: `Table '${tableName}' not found` }], isError: true, }; } - // Minimal mode: return only field names, types, and key metadata + const parsed = parseIniSchema(iniContent); + if (minimal) { - const minimalSchema = {}; - for (const [key, value] of Object.entries(table)) { - if (value && typeof value === 'object' && value.type) { - const field = { type: value.type }; - if (value.label) field.label = value.label; - if (value.optionsType) field.optionsType = value.optionsType; - if (value.optionsTablename) field.optionsTablename = value.optionsTablename; - if (value.isRequired) field.isRequired = value.isRequired; - minimalSchema[key] = field; - } else if (typeof value !== 'object') { - // Keep top-level scalar metadata (menuName, menuType, enlace, etc.) - minimalSchema[key] = value; - } + const minimalSchema = { menuName: parsed.menuName, menuType: parsed.menuType, enlace: parsed.enlace }; + const minFields = {}; + for (const [key, value] of Object.entries(parsed.fields || {})) { + minFields[key] = { type: value.type }; + if (value.label) minFields[key].label = value.label; } - return { - content: [{ type: "text", text: JSON.stringify(minimalSchema, null, 2) }], - }; + minimalSchema.fields = minFields; + return { content: [{ type: "text", text: JSON.stringify(minimalSchema, null, 2) }] }; } - return { - content: [{ type: "text", text: JSON.stringify(table, null, 2) }], - }; + return { content: [{ type: "text", text: JSON.stringify(parsed, null, 2) }] }; } catch (error) { return handleToolError(error, 'get_table_schema', { tableName }); } }) ); } - -export function registerUpdateTableSchemaTool(server) { - server.tool( - "update_table_schema", - `Update table-level metadata (menuName, menuOrder, enlace, seo_metas). NOT for field operations — use edit_table_field instead. - - Tables WITHOUT 'cms_' prefix. 2-step process: saves to SAAS server, then triggers website schema update.`, - withAuthParams({ - tableName: z.string().describe("Name of the table to update"), - schema: z.object({}).passthrough().describe("Schema object with fields objects ( like reference schema table ) to add or update. By default, this is merged with the existing schema."), - overwrite: z.boolean().optional().describe("If true, replaces the ENTIRE schema with the provided one (deleting missing fields). If false (default), merges with existing schema."), - }), - { readOnlyHint: false, destructiveHint: false }, - withAuth(async ({ tableName, schema, overwrite = false }, extra) => { - try { - // Validate required parameters - const validationError = validateRequired({ tableName, schema }, ['tableName', 'schema'], 'update_table_schema'); - if (validationError) return validationError; - - let schemaToSave; - - const credentials = await getSessionCredentials(extra.sessionId); - - if (overwrite) { - // If overwrite is true, use the provided schema directly - schemaToSave = { ...schema }; - } else { - // Step 1: Fetch current schema to preserve existing fields - const getResponse = await AcaiHttpClient.saasPostRequest( - { - id: tableName - }, - credentials.token - ); - - if (!getResponse.data.success) { - return { - content: [{ type: "text", text: "Error fetching current schema: " + JSON.stringify(getResponse.data) }], - isError: true, - }; - } - - const currentTable = getResponse.data.data; - - if (!currentTable) { - return { - content: [{ type: "text", text: `Table '${tableName}' not found. Please create it first using create_table.` }], - isError: true, - }; - } - - // Step 2: Merge new schema into existing schema - schemaToSave = mergeTableSchemas(currentTable, schema); - } - - normalizeSchemaForSave(schemaToSave); - - // Remove tableName from schema (as done in frontend) - delete schemaToSave.tableName; - - // Step 3: Save merged schema to SAAS server (PUT request) - const saasResponse = await AcaiHttpClient.saasPutRequest( - { - action: "saveSchema", - schema: schemaToSave, - id: tableName, - }, - credentials.token - ); - - // SAAS returns {success: true} not {result: true} - if (!saasResponse.data.success && !saasResponse.data.result) { - return { - content: [{ type: "text", text: "Error saving schema to SAAS: " + JSON.stringify(saasResponse.data) }], - isError: true, - }; - } - - // Step 4: Trigger schema update on website - const client = await getApiClient(extra.sessionId); - const updateResponse = await client.post("/cms/lib/viewer_functions.php", await getCommonParams(extra.sessionId, { - action_ws: "updateAllSchemas", - tokenHash: credentials.tokenHash - })); - - // Check for website update errors - let updateError = handleApiResponse(updateResponse.data, 'update_table_schema'); - if (updateError) return updateError; - - return { - content: [{ - type: "text", - text: JSON.stringify({ - success: true, - message: overwrite ? "Schema overwritten successfully" : "Schema updated successfully (merged with existing fields)", - mergedFields: Object.keys(schemaToSave), - saasResponse: saasResponse.data, - webResponse: updateResponse.data - }, null, 2) - }], - }; - } catch (error) { - return handleToolError(error, 'update_table_schema', { tableName, overwrite }); - } - }) - ); -}