diff --git a/docs/mcp-tools-reference.md b/docs/mcp-tools-reference.md index 3c71c11..4da3f14 100644 --- a/docs/mcp-tools-reference.md +++ b/docs/mcp-tools-reference.md @@ -35,6 +35,8 @@ | `navigate_browser` | Navegación | Navegar el browser del frontend a una URL | | `save_project_styles` | Proyecto | Guardar resumen de estilos en docs/project-styles.md | | `rollback_git` | Git | Recuperar cambios de git remoto | +| `get_layout_field` | Layout | Lee el source de los campos globales del layout.json: style, javascript, header, footer | +| `set_layout_field` | Layout | Reemplaza un campo global del layout.json. **USA ESTA TOOL** para editar header/footer — NO toques los .tpl directos | ## Flujos de trabajo @@ -116,3 +118,42 @@ Reglas: 5. **recordId para imágenes** es el `num` de `builder_custom`, NO el sectionId del módulo 6. Tras `set_module_config_vars`, TODAS las variables del módulo (incluyendo upload) reciben config-vars automáticamente 7. Si el token expira (error 403), usar `refresh_acai_token` + +## Layout global (header, footer, style, javascript) + +Los 4 campos globales del proyecto (`style.css`, `script.js`, `header`, `footer`) viven en `cms/lib/plugins/builder_saas/layout.json`. + +### REGLA CRÍTICA + +**NUNCA uses `acai-view`, `acai-line-replace`, `acai-write` ni `acai-delete` sobre**: +- `cms/lib/plugins/builder_saas/layout.json` +- `template/estandar/modulos/custom-header-twig/*` +- `template/estandar/modulos/custom-footer-twig/*` +- `template/estandar/modulos/custom-header/*` +- `template/estandar/modulos/custom-footer/*` + +Esos ficheros son **artefactos generados** a partir del `layout.json`. Editarlos directamente provoca: +- Desincronización con `layout.json.{header,footer}ModuleCustom.htmlParsed`. +- Sobrescritura de tus cambios cuando el usuario abre el builder visual y guarda. +- Comportamiento inconsistente entre el render público y el builder. + +### Workflow correcto + +Para leer: +``` +get_layout_field({ field: "header" }) // devuelve el source Twig del header +get_layout_field({ field: "footer" }) +get_layout_field({ field: "style" }) // CSS global +get_layout_field({ field: "javascript" }) // JS global +``` + +Para editar: +``` +set_layout_field({ field: "footer", content: "" }) +``` + +El backend: +1. Escribe el source en `layout.json.{field}`. +2. Sincroniza `layout.json.{field}ModuleCustom.htmlParsed`. +3. Regenera los `.tpl` del módulo `custom-{field}-twig/`. +4. Compila el Twig a PHP. diff --git a/mcp-server/tools/files/delete.js b/mcp-server/tools/files/delete.js index a732e59..016106e 100644 --- a/mcp-server/tools/files/delete.js +++ b/mcp-server/tools/files/delete.js @@ -1,6 +1,7 @@ import { z } from "zod"; import { handleToolError, validateRequired } from "../helpers/errorHandler.js"; import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js"; +import { isProtectedLayoutPath, buildProtectedLayoutPathError } from "./protectedPaths.js"; export function registerAcaiDeleteTool(server) { server.tool( @@ -16,6 +17,10 @@ export function registerAcaiDeleteTool(server) { const validationError = validateRequired({ file_path }, ["file_path"], "acai-delete"); if (validationError) return validationError; + if (isProtectedLayoutPath(file_path)) { + return buildProtectedLayoutPathError(file_path); + } + const { projectSlug, projectDir } = getCurrentProjectInfo(); const result = await callLocalFileEndpoint("POST", "/api/files/delete", { project: projectSlug, diff --git a/mcp-server/tools/files/lineReplace.js b/mcp-server/tools/files/lineReplace.js index 0340880..31c8186 100644 --- a/mcp-server/tools/files/lineReplace.js +++ b/mcp-server/tools/files/lineReplace.js @@ -1,6 +1,7 @@ import { z } from "zod"; import { handleToolError, validateRequired } from "../helpers/errorHandler.js"; import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js"; +import { isProtectedLayoutPath, buildProtectedLayoutPathError } from "./protectedPaths.js"; export function registerAcaiLineReplaceTool(server) { server.tool( @@ -24,6 +25,10 @@ export function registerAcaiLineReplaceTool(server) { ); if (validationError) return validationError; + if (isProtectedLayoutPath(file_path)) { + return buildProtectedLayoutPathError(file_path); + } + const { projectSlug, projectDir } = getCurrentProjectInfo(); const result = await callLocalFileEndpoint("POST", "/api/files/line-replace", { project: projectSlug, diff --git a/mcp-server/tools/files/protectedPaths.js b/mcp-server/tools/files/protectedPaths.js new file mode 100644 index 0000000..dbcad88 --- /dev/null +++ b/mcp-server/tools/files/protectedPaths.js @@ -0,0 +1,39 @@ +// Shared guard for generated layout artifacts. The global layout.json and the +// custom-header/custom-footer module folders are regenerated from the layout +// pipeline (see set_layout_field). Editing them directly leaves the JSON source +// out of sync and the visual builder overwrites the agent changes on next save. + +const PROTECTED_LAYOUT_PATHS = [ + "cms/lib/plugins/builder_saas/layout.json", + "template/estandar/modulos/custom-header-twig/", + "template/estandar/modulos/custom-footer-twig/", + "template/estandar/modulos/custom-header/", + "template/estandar/modulos/custom-footer/", +]; + +// Returns true when `relPath` points at the layout.json or any of the +// generated custom-{header,footer}[-twig] module folders. +export function isProtectedLayoutPath(relPath) { + if (!relPath) return false; + const norm = String(relPath).replace(/^\/+/, ""); + return PROTECTED_LAYOUT_PATHS.some(p => { + // Folder entries end with "/" -> prefix match on the normalized path. + // File entries (no trailing slash) -> exact match only. + if (p.endsWith("/")) return norm === p.slice(0, -1) || norm.startsWith(p); + return norm === p; + }); +} + +// Builds a consistent MCP error response pointing the agent to set_layout_field. +export function buildProtectedLayoutPathError(relPath) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: false, + error: `Forbidden path: ${relPath} is a generated artifact of the global layout. Use set_layout_field instead with field='header' (for custom-header-twig) or field='footer' (for custom-footer-twig).`, + }, null, 2), + }], + isError: true, + }; +} diff --git a/mcp-server/tools/files/write.js b/mcp-server/tools/files/write.js index 9cbd3e3..887cbcd 100644 --- a/mcp-server/tools/files/write.js +++ b/mcp-server/tools/files/write.js @@ -1,6 +1,7 @@ import { z } from "zod"; import { handleToolError, validateRequired } from "../helpers/errorHandler.js"; import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js"; +import { isProtectedLayoutPath, buildProtectedLayoutPathError } from "./protectedPaths.js"; export function registerAcaiWriteTool(server) { server.tool( @@ -23,6 +24,10 @@ Before writing, check the matching documentation for the file type: const validationError = validateRequired({ file_path }, ["file_path"], "acai-write"); if (validationError) return validationError; + if (isProtectedLayoutPath(file_path)) { + return buildProtectedLayoutPathError(file_path); + } + const { projectSlug, projectDir } = getCurrentProjectInfo(); const result = await callLocalFileEndpoint("POST", "/api/files/write", { project: projectSlug, diff --git a/mcp-server/tools/layout/get_layout_field.js b/mcp-server/tools/layout/get_layout_field.js index 6e94793..efe0c2e 100644 --- a/mcp-server/tools/layout/get_layout_field.js +++ b/mcp-server/tools/layout/get_layout_field.js @@ -13,7 +13,7 @@ import { getCurrentProjectInfo } from "../files/helpers.js"; export function registerGetLayoutFieldTool(server) { server.tool( "get_layout_field", - `Get the content of a global layout field of the project. Supported: 'style' (global CSS injected in all pages), 'javascript' (global JS), 'header' (HTML/Twig source of the site header), 'footer' (HTML/Twig source of the site footer). Use this before set_layout_field to see the current content.`, + `Get the content of a global layout field: 'style', 'javascript', 'header' or 'footer'. For header/footer this is the source of truth — the .tpl files in template/estandar/modulos/custom-{header,footer}-twig/ are generated artifacts. Always prefer this over acai-view on those .tpl files when you need to read the global header/footer source.`, withAuthParams({ field: z.enum(["style", "javascript", "header", "footer"]).describe("Which layout field: 'style', 'javascript', 'header' or 'footer'"), }), diff --git a/mcp-server/tools/layout/set_layout_field.js b/mcp-server/tools/layout/set_layout_field.js index 9efd060..10f5daa 100644 --- a/mcp-server/tools/layout/set_layout_field.js +++ b/mcp-server/tools/layout/set_layout_field.js @@ -14,7 +14,7 @@ import { getCurrentProjectInfo } from "../files/helpers.js"; export function registerSetLayoutFieldTool(server) { server.tool( "set_layout_field", - `Replace the content of a global layout field. 'style'/'javascript' are simple string fields injected via CDN-like URLs (no regeneration needed). 'header'/'footer' are more complex: saving them triggers a server-side pipeline that regenerates the compiled PHP, Twig module files, and TWIG-compiled templates — changes are visible immediately. Destructive: overwrites existing content. Prefer reading with get_layout_field first.`, + `Replace the content of a global layout field: 'style' (CSS), 'javascript' (JS), 'header' (Twig source of the site header), 'footer' (Twig source). CRITICAL: for header/footer, ALWAYS use this tool instead of editing template/estandar/modulos/custom-header-twig/index-base.tpl or custom-footer-twig/index-base.tpl directly with acai-line-replace or acai-write. Editing those .tpl files directly leaves layout.json.{header,footer} out of sync and the visual builder will overwrite your changes on its next save. This tool writes the source, syncs layout.json, regenerates the compiled module files, and runs the TWIG compilation in one atomic pipeline. Destructive: overwrites the full content. Pair with get_layout_field first to read the current source.`, withAuthParams({ field: z.enum(["style", "javascript", "header", "footer"]).describe("Which layout field: 'style', 'javascript', 'header' or 'footer'"), content: z.string().describe("Full replacement content. Max 500KB."),