diff --git a/mcp-server/tools/files/delete.js b/mcp-server/tools/files/delete.js index 016106e..033ac5b 100644 --- a/mcp-server/tools/files/delete.js +++ b/mcp-server/tools/files/delete.js @@ -1,7 +1,12 @@ import { z } from "zod"; import { handleToolError, validateRequired } from "../helpers/errorHandler.js"; import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js"; -import { isProtectedLayoutPath, buildProtectedLayoutPathError } from "./protectedPaths.js"; +import { + isProtectedLayoutPath, + buildProtectedLayoutPathError, + isProtectedSchemaPath, + buildProtectedSchemaPathError, +} from "./protectedPaths.js"; export function registerAcaiDeleteTool(server) { server.tool( @@ -21,6 +26,10 @@ export function registerAcaiDeleteTool(server) { return buildProtectedLayoutPathError(file_path); } + if (isProtectedSchemaPath(file_path)) { + return buildProtectedSchemaPathError(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 31c8186..861babf 100644 --- a/mcp-server/tools/files/lineReplace.js +++ b/mcp-server/tools/files/lineReplace.js @@ -1,7 +1,12 @@ import { z } from "zod"; import { handleToolError, validateRequired } from "../helpers/errorHandler.js"; import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js"; -import { isProtectedLayoutPath, buildProtectedLayoutPathError } from "./protectedPaths.js"; +import { + isProtectedLayoutPath, + buildProtectedLayoutPathError, + isProtectedSchemaPath, + buildProtectedSchemaPathError, +} from "./protectedPaths.js"; export function registerAcaiLineReplaceTool(server) { server.tool( @@ -29,6 +34,10 @@ export function registerAcaiLineReplaceTool(server) { return buildProtectedLayoutPathError(file_path); } + if (isProtectedSchemaPath(file_path)) { + return buildProtectedSchemaPathError(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 index dbcad88..e116cae 100644 --- a/mcp-server/tools/files/protectedPaths.js +++ b/mcp-server/tools/files/protectedPaths.js @@ -11,11 +11,26 @@ const PROTECTED_LAYOUT_PATHS = [ "template/estandar/modulos/custom-footer/", ]; +// Table schemas live here. They are the .ini.php mirror of the real MySQL +// structure: editing the file by hand does not run any DDL, so the schema and +// the database drift apart (and the CMS keeps serving stale cached metadata). +const SCHEMA_DIR_PREFIX = "cms/data/schema/"; + +// Normalizes a relative path: drops leading slashes and "./" segments so +// "/cms/...", "./cms/..." and "cms/..." all compare equal. +function normalizeRelPath(relPath) { + let norm = String(relPath).replace(/^\/+/, ""); + while (norm.startsWith("./")) { + norm = norm.slice(2).replace(/^\/+/, ""); + } + return norm; +} + // 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(/^\/+/, ""); + const norm = normalizeRelPath(relPath); return PROTECTED_LAYOUT_PATHS.some(p => { // Folder entries end with "/" -> prefix match on the normalized path. // File entries (no trailing slash) -> exact match only. @@ -37,3 +52,26 @@ export function buildProtectedLayoutPathError(relPath) { isError: true, }; } + +// Returns true when `relPath` points inside cms/data/schema/ (the table schema +// directory), so file tools can bail out before hitting the Python endpoint. +export function isProtectedSchemaPath(relPath) { + if (!relPath) return false; + const norm = normalizeRelPath(relPath); + if (!norm) return false; + return norm === SCHEMA_DIR_PREFIX.slice(0, -1) || norm.startsWith(SCHEMA_DIR_PREFIX); +} + +// Builds a consistent MCP error response pointing the agent to the table tools. +export function buildProtectedSchemaPathError(relPath) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: false, + error: `Forbidden path: ${relPath} lives in ${SCHEMA_DIR_PREFIX} and table schemas are read-only through the file tools (acai-write, acai-line-replace, acai-delete). To change the structure use the table tools instead - create_table, create_field, update_field, delete_field, update_table_metadata, delete_table - which run the real DDL in MySQL and refresh the caches. To read a schema use get_table_schema or acai-view.`, + }, null, 2), + }], + isError: true, + }; +} diff --git a/mcp-server/tools/files/write.js b/mcp-server/tools/files/write.js index 887cbcd..9dcba81 100644 --- a/mcp-server/tools/files/write.js +++ b/mcp-server/tools/files/write.js @@ -1,7 +1,12 @@ import { z } from "zod"; import { handleToolError, validateRequired } from "../helpers/errorHandler.js"; import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js"; -import { isProtectedLayoutPath, buildProtectedLayoutPathError } from "./protectedPaths.js"; +import { + isProtectedLayoutPath, + buildProtectedLayoutPathError, + isProtectedSchemaPath, + buildProtectedSchemaPathError, +} from "./protectedPaths.js"; export function registerAcaiWriteTool(server) { server.tool( @@ -28,6 +33,10 @@ Before writing, check the matching documentation for the file type: return buildProtectedLayoutPathError(file_path); } + if (isProtectedSchemaPath(file_path)) { + return buildProtectedSchemaPathError(file_path); + } + const { projectSlug, projectDir } = getCurrentProjectInfo(); const result = await callLocalFileEndpoint("POST", "/api/files/write", { project: projectSlug,