Initial commit
This commit is contained in:
108
mcp-server/tools/auth/index.js
Normal file
108
mcp-server/tools/auth/index.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import { z } from "zod";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import axios from "axios";
|
||||
import { sessionCredentials } from "../../auth/credentials.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
|
||||
const LOCAL_SERVER_URL = `http://localhost:${process.env.ACAI_HOST_PORT || 29871}`;
|
||||
|
||||
export function registerAuthTools(server) {
|
||||
server.tool(
|
||||
"refresh_acai_token",
|
||||
`Refresh the Acai JWT token when it has expired (403 "Token no válido" errors). This re-reads the token from the .acai file on disk. If the token on disk is also expired, it calls the Python server to renew it. Use this tool when any other tool fails with a 403 token error.`,
|
||||
withAuthParams({}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
async (_args, extra) => {
|
||||
try {
|
||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
||||
const acaiFilePath = projectDir ? path.join(projectDir, ".acai") : "";
|
||||
|
||||
if (!acaiFilePath) {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ success: false, error: "ACAI_PROJECT_DIR not set" }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Step 1: Try reading fresh token from .acai (Python server may have already refreshed it)
|
||||
let token = "";
|
||||
let tokenHash = "";
|
||||
let domain = "";
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(acaiFilePath, "utf-8"));
|
||||
token = data.token || "";
|
||||
tokenHash = data.tokenHash || "";
|
||||
domain = data.domain || "";
|
||||
} catch (e) {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ success: false, error: `Cannot read .acai: ${e.message}` }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Check if token is expired by decoding JWT
|
||||
let isExpired = false;
|
||||
try {
|
||||
const payload = token.split(".")[1];
|
||||
const decoded = JSON.parse(Buffer.from(payload, "base64").toString());
|
||||
isExpired = Date.now() / 1000 > (decoded.exp || 0) - 300;
|
||||
} catch {
|
||||
isExpired = true;
|
||||
}
|
||||
|
||||
// Step 3: If expired, ask Python server to refresh it
|
||||
if (isExpired) {
|
||||
try {
|
||||
// Call the compile-module endpoint pattern — but we need a refresh endpoint
|
||||
// Use the server's existing auto-refresh: just call any endpoint that triggers refresh
|
||||
// The simplest: GET /api/projects which auto-refreshes expired tokens
|
||||
const res = await axios.get(`${LOCAL_SERVER_URL}/api/projects`, { timeout: 15000 });
|
||||
// Re-read .acai after server refreshed it
|
||||
const data = JSON.parse(fs.readFileSync(acaiFilePath, "utf-8"));
|
||||
token = data.token || "";
|
||||
tokenHash = data.tokenHash || "";
|
||||
} catch (e) {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ success: false, error: `Token refresh failed: ${e.message}` }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Update credentials in memory
|
||||
const webUrl = process.env.ACAI_WEB_URL || "";
|
||||
const website = domain || process.env.ACAI_WEBSITE || "";
|
||||
const freshCreds = {
|
||||
token,
|
||||
tokenHash,
|
||||
website,
|
||||
web_url: webUrl,
|
||||
profileName: "stdio",
|
||||
role: "developer",
|
||||
};
|
||||
sessionCredentials.set("_default", freshCreds);
|
||||
if (extra?.sessionId) {
|
||||
sessionCredentials.set(extra.sessionId, freshCreds);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: "Token refreshed successfully",
|
||||
expired_before: isExpired,
|
||||
domain: website,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ success: false, error: error.message }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
46
mcp-server/tools/files/delete.js
Normal file
46
mcp-server/tools/files/delete.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { z } from "zod";
|
||||
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
|
||||
|
||||
export function registerAcaiDeleteTool(server) {
|
||||
server.tool(
|
||||
"acai-delete",
|
||||
"Delete a file inside the project. Destructive operation.",
|
||||
{
|
||||
file_path: z.string().describe("Path relative to the project root"),
|
||||
expected_sha256: z.string().optional().describe("Optional safety check before deletion"),
|
||||
},
|
||||
{ readOnlyHint: false, destructiveHint: true },
|
||||
async ({ file_path, expected_sha256 }) => {
|
||||
try {
|
||||
const validationError = validateRequired({ file_path }, ["file_path"], "acai-delete");
|
||||
if (validationError) return validationError;
|
||||
|
||||
const { projectSlug, projectDir } = getCurrentProjectInfo();
|
||||
const result = await callLocalFileEndpoint("POST", "/api/files/delete", {
|
||||
project: projectSlug,
|
||||
projectDir: projectDir,
|
||||
relativePath: file_path,
|
||||
expectedSha256: expected_sha256 || "",
|
||||
});
|
||||
if (!result.data?.success) {
|
||||
return buildLocalFileErrorResponse("acai-delete", result, { file_path });
|
||||
}
|
||||
const data = result.data;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
file_path: data.filePath,
|
||||
deleted: data.deleted,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, "acai-delete", { file_path });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
51
mcp-server/tools/files/glob.js
Normal file
51
mcp-server/tools/files/glob.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { z } from "zod";
|
||||
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
|
||||
|
||||
export function registerAcaiGlobTool(server) {
|
||||
server.tool(
|
||||
"acai-glob",
|
||||
"Find project files by path pattern. Returns compact relative paths only.",
|
||||
{
|
||||
pattern: z.string().describe("Glob-style pattern relative to the project root, e.g. 'template/estandar/modulos/**/index-base.tpl'"),
|
||||
base_path: z.string().optional().describe("Optional base directory relative to the project root"),
|
||||
limit: z.number().int().positive().max(200).optional().describe("Maximum number of paths to return"),
|
||||
},
|
||||
{ readOnlyHint: true, destructiveHint: false },
|
||||
async ({ pattern, base_path, limit }) => {
|
||||
try {
|
||||
const validationError = validateRequired({ pattern }, ["pattern"], "acai-glob");
|
||||
if (validationError) return validationError;
|
||||
|
||||
const { projectSlug, projectDir } = getCurrentProjectInfo();
|
||||
const result = await callLocalFileEndpoint("GET", "/api/files/glob", null, {
|
||||
project: projectSlug,
|
||||
projectDir,
|
||||
pattern,
|
||||
basePath: base_path,
|
||||
limit,
|
||||
});
|
||||
if (!result.data?.success) {
|
||||
return buildLocalFileErrorResponse("acai-glob", result, { pattern, base_path });
|
||||
}
|
||||
const data = result.data;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
pattern: data.pattern,
|
||||
base_path: data.basePath,
|
||||
matches: data.matches,
|
||||
total_matches: data.totalMatches,
|
||||
truncated: data.truncated,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, "acai-glob", { pattern, base_path });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
65
mcp-server/tools/files/grep.js
Normal file
65
mcp-server/tools/files/grep.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { z } from "zod";
|
||||
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
|
||||
|
||||
export function registerAcaiGrepTool(server) {
|
||||
server.tool(
|
||||
"acai-grep",
|
||||
"Search text inside project files with compact line-level results. Supports optional glob filtering.",
|
||||
{
|
||||
pattern: z.string().describe("Text or regex pattern to search for"),
|
||||
base_path: z.string().optional().describe("Optional base directory relative to the project root"),
|
||||
glob: z.string().optional().describe("Optional file path glob filter, e.g. '**/index-base.tpl'"),
|
||||
limit: z.number().int().positive().max(100).optional().describe("Maximum number of matches to return"),
|
||||
case_sensitive: z.boolean().optional().describe("Whether matching should be case-sensitive"),
|
||||
regex: z.boolean().optional().describe("Treat pattern as a regular expression"),
|
||||
},
|
||||
{ readOnlyHint: true, destructiveHint: false },
|
||||
async ({ pattern, base_path, glob, limit, case_sensitive, regex }) => {
|
||||
try {
|
||||
const validationError = validateRequired({ pattern }, ["pattern"], "acai-grep");
|
||||
if (validationError) return validationError;
|
||||
|
||||
const { projectSlug, projectDir } = getCurrentProjectInfo();
|
||||
const result = await callLocalFileEndpoint("POST", "/api/files/grep", {
|
||||
project: projectSlug,
|
||||
projectDir,
|
||||
pattern,
|
||||
basePath: base_path,
|
||||
glob,
|
||||
limit,
|
||||
caseSensitive: case_sensitive,
|
||||
regex,
|
||||
});
|
||||
if (!result.data?.success) {
|
||||
return buildLocalFileErrorResponse("acai-grep", result, { pattern, base_path, glob });
|
||||
}
|
||||
const data = result.data;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
pattern: data.pattern,
|
||||
base_path: data.basePath,
|
||||
glob: data.glob,
|
||||
regex: data.regex,
|
||||
case_sensitive: data.caseSensitive,
|
||||
files_scanned: data.filesScanned,
|
||||
matches: data.matches.map((match) => ({
|
||||
file_path: match.filePath,
|
||||
line: match.line,
|
||||
match_preview: match.matchPreview,
|
||||
})),
|
||||
total_matches: data.totalMatches,
|
||||
truncated: data.truncated,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, "acai-grep", { pattern, base_path, glob });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
60
mcp-server/tools/files/helpers.js
Normal file
60
mcp-server/tools/files/helpers.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import axios from "axios";
|
||||
import path from "path";
|
||||
import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../../config/index.js";
|
||||
|
||||
export function getCurrentProjectInfo() {
|
||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
||||
if (!projectDir) {
|
||||
throw new Error("ACAI_PROJECT_DIR not set");
|
||||
}
|
||||
return {
|
||||
projectDir,
|
||||
projectSlug: path.basename(path.resolve(projectDir)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function callLocalFileEndpoint(method, endpoint, payload = null, query = null) {
|
||||
const headers = getLocalServerHeaders();
|
||||
if (method === "GET") {
|
||||
const response = await axios.get(`${LOCAL_SERVER_URL}${endpoint}`, {
|
||||
params: query || undefined,
|
||||
headers,
|
||||
timeout: 30000,
|
||||
validateStatus: (status) => status < 600,
|
||||
});
|
||||
return { status: response.status, data: response.data };
|
||||
}
|
||||
|
||||
const response = await axios.post(`${LOCAL_SERVER_URL}${endpoint}`, payload || {}, {
|
||||
headers,
|
||||
timeout: 30000,
|
||||
validateStatus: (status) => status < 600,
|
||||
});
|
||||
return { status: response.status, data: response.data };
|
||||
}
|
||||
|
||||
export function buildLocalFileErrorResponse(toolName, result, extra = {}) {
|
||||
const payload = result?.data || {};
|
||||
const message =
|
||||
payload.message ||
|
||||
payload.error ||
|
||||
payload.compileError ||
|
||||
`HTTP ${result?.status || 500}`;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: {
|
||||
code: `HTTP_${result.status}`,
|
||||
message,
|
||||
context: toolName,
|
||||
...extra,
|
||||
...payload,
|
||||
},
|
||||
}, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
15
mcp-server/tools/files/index.js
Normal file
15
mcp-server/tools/files/index.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { registerAcaiViewTool } from "./view.js";
|
||||
import { registerAcaiWriteTool } from "./write.js";
|
||||
import { registerAcaiLineReplaceTool } from "./lineReplace.js";
|
||||
import { registerAcaiDeleteTool } from "./delete.js";
|
||||
import { registerAcaiGlobTool } from "./glob.js";
|
||||
import { registerAcaiGrepTool } from "./grep.js";
|
||||
|
||||
export function registerFileTools(server) {
|
||||
registerAcaiViewTool(server);
|
||||
registerAcaiGlobTool(server);
|
||||
registerAcaiGrepTool(server);
|
||||
registerAcaiWriteTool(server);
|
||||
registerAcaiLineReplaceTool(server);
|
||||
registerAcaiDeleteTool(server);
|
||||
}
|
||||
67
mcp-server/tools/files/lineReplace.js
Normal file
67
mcp-server/tools/files/lineReplace.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { z } from "zod";
|
||||
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
|
||||
|
||||
export function registerAcaiLineReplaceTool(server) {
|
||||
server.tool(
|
||||
"acai-line-replace",
|
||||
"Replace a validated line block in an existing file. Preferred for editing existing files while minimizing token usage.",
|
||||
{
|
||||
file_path: z.string().describe("Path relative to the project root"),
|
||||
first_replaced_line: z.number().int().positive().describe("1-indexed first line of the target block"),
|
||||
last_replaced_line: z.number().int().positive().describe("1-indexed last line of the target block"),
|
||||
search: z.string().describe("Expected current content for validation. Must match the selected block exactly."),
|
||||
replace: z.string().describe("Replacement content for the selected block"),
|
||||
expected_sha256: z.string().optional().describe("Optional full-file sha check from a prior acai-view"),
|
||||
},
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
async ({ file_path, first_replaced_line, last_replaced_line, search, replace, expected_sha256 }) => {
|
||||
try {
|
||||
const validationError = validateRequired(
|
||||
{ file_path, first_replaced_line, last_replaced_line, search },
|
||||
["file_path", "first_replaced_line", "last_replaced_line", "search"],
|
||||
"acai-line-replace"
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const { projectSlug, projectDir } = getCurrentProjectInfo();
|
||||
const result = await callLocalFileEndpoint("POST", "/api/files/line-replace", {
|
||||
project: projectSlug,
|
||||
projectDir: projectDir,
|
||||
relativePath: file_path,
|
||||
firstLine: first_replaced_line,
|
||||
lastLine: last_replaced_line,
|
||||
search,
|
||||
replace,
|
||||
expectedSha256: expected_sha256 || "",
|
||||
});
|
||||
if (!result.data?.success) {
|
||||
return buildLocalFileErrorResponse("acai-line-replace", result, {
|
||||
file_path,
|
||||
first_replaced_line,
|
||||
last_replaced_line,
|
||||
});
|
||||
}
|
||||
const data = result.data;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
file_path: data.filePath,
|
||||
first_replaced_line: data.firstLine,
|
||||
last_replaced_line: data.lastLine,
|
||||
new_sha256: data.newSha256,
|
||||
changed: data.changed,
|
||||
compiled: data.compiled || false,
|
||||
compile_result: data.compileResult || null,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, "acai-line-replace", { file_path, first_replaced_line, last_replaced_line });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
60
mcp-server/tools/files/view.js
Normal file
60
mcp-server/tools/files/view.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { z } from "zod";
|
||||
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
|
||||
|
||||
export function registerAcaiViewTool(server) {
|
||||
server.tool(
|
||||
"acai-view",
|
||||
"Read a project file with optional line ranges. Returns only the requested slice plus compact metadata for safe incremental edits.",
|
||||
{
|
||||
file_path: z.string().describe("Path relative to the project root"),
|
||||
start_line: z.number().int().positive().optional().describe("1-indexed start line. Defaults to 1."),
|
||||
end_line: z.number().int().positive().optional().describe("1-indexed end line. If omitted, the server returns a bounded chunk."),
|
||||
},
|
||||
{ readOnlyHint: true, destructiveHint: false },
|
||||
async ({ file_path, start_line, end_line }) => {
|
||||
try {
|
||||
const validationError = validateRequired({ file_path }, ["file_path"], "acai-view");
|
||||
if (validationError) return validationError;
|
||||
|
||||
if (end_line !== undefined && start_line !== undefined && end_line < start_line) {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ success: false, error: "end_line must be greater than or equal to start_line" }, null, 2) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const { projectSlug } = getCurrentProjectInfo();
|
||||
const result = await callLocalFileEndpoint("GET", "/api/files/read", null, {
|
||||
project: projectSlug,
|
||||
projectDir: getCurrentProjectInfo().projectDir,
|
||||
relativePath: file_path,
|
||||
startLine: start_line,
|
||||
endLine: end_line,
|
||||
});
|
||||
if (!result.data?.success) {
|
||||
return buildLocalFileErrorResponse("acai-view", result, { file_path });
|
||||
}
|
||||
const data = result.data;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
file_path: data.filePath,
|
||||
start_line: data.startLine,
|
||||
end_line: data.endLine,
|
||||
total_lines: data.totalLines,
|
||||
sha256: data.sha256,
|
||||
content: data.content,
|
||||
truncated: data.truncated,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, "acai-view", { file_path });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
59
mcp-server/tools/files/write.js
Normal file
59
mcp-server/tools/files/write.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { z } from "zod";
|
||||
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
|
||||
|
||||
export function registerAcaiWriteTool(server) {
|
||||
server.tool(
|
||||
"acai-write",
|
||||
`Write a full file inside the project. Use for new files or full rewrites. Prefer acai-line-replace for targeted edits.
|
||||
|
||||
Before writing, check the matching documentation for the file type:
|
||||
- If the file is an index-base template (\`index-base.tpl\` or \`index-base.html\`), make sure you have read \`docs/module-creation-guide.md\` and \`docs/builder-fields.md\`
|
||||
- If the file is a \`.js\` or \`.css\`, make sure you have read \`docs/css-js-conventions.md\`
|
||||
- If the file is a module or global hook PHP file, make sure you have read \`docs/hooks-and-api.md\``,
|
||||
{
|
||||
file_path: z.string().describe("Path relative to the project root"),
|
||||
content: z.string().describe("Full file content to write"),
|
||||
mode: z.enum(["create_or_overwrite", "create_only"]).optional().default("create_or_overwrite").describe("Write mode"),
|
||||
expected_sha256: z.string().optional().describe("Optional optimistic concurrency check from a prior acai-view"),
|
||||
},
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
async ({ file_path, content, mode = "create_or_overwrite", expected_sha256 }) => {
|
||||
try {
|
||||
const validationError = validateRequired({ file_path }, ["file_path"], "acai-write");
|
||||
if (validationError) return validationError;
|
||||
|
||||
const { projectSlug, projectDir } = getCurrentProjectInfo();
|
||||
const result = await callLocalFileEndpoint("POST", "/api/files/write", {
|
||||
project: projectSlug,
|
||||
projectDir: projectDir,
|
||||
relativePath: file_path,
|
||||
content,
|
||||
mode,
|
||||
expectedSha256: expected_sha256 || "",
|
||||
});
|
||||
if (!result.data?.success) {
|
||||
return buildLocalFileErrorResponse("acai-write", result, { file_path });
|
||||
}
|
||||
const data = result.data;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
file_path: data.filePath,
|
||||
created: data.created,
|
||||
overwritten: data.overwritten,
|
||||
sha256: data.sha256,
|
||||
compiled: data.compiled || false,
|
||||
compile_result: data.compileResult || null,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, "acai-write", { file_path });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
357
mcp-server/tools/helpers/ACAI_ENDPOINTS.md
Normal file
357
mcp-server/tools/helpers/ACAI_ENDPOINTS.md
Normal file
@@ -0,0 +1,357 @@
|
||||
# Acai CMS Endpoints Reference
|
||||
|
||||
Este documento mapea todos los endpoints de Acai CMS utilizados por las herramientas MCP.
|
||||
|
||||
## Endpoints Base
|
||||
|
||||
- **CMS Admin**: `https://[website]/admin.php` - Panel administrativo principal
|
||||
- **Viewer Functions**: `https://[website]/cms/lib/viewer_functions.php` - API de funciones Acai
|
||||
- **SAAS API**: `https://ws.cocosolution.com/api/schemas/` - API SaaS para esquemas
|
||||
- **File Upload**: `https://[website]/lib/menus/modals/plupload/multiupload/upload.php` - Subir archivos
|
||||
|
||||
## Categoría: Módulos (saveApartados)
|
||||
|
||||
### 1. Generar módulo desde HTML
|
||||
**Endpoint**: `https://acai.cms.cocosolution.com/admin.php?menu=apartados&action=edit&generateModuleFromString=1`
|
||||
**Método**: POST
|
||||
**Usado por**: `save_module`
|
||||
**Headers**: `Content-Type: application/json`, `X-Acai-Token`
|
||||
**Payload**: moduleData object con html, htmlParsed, vars, etc.
|
||||
|
||||
### 2. Obtener esquemas de módulos
|
||||
**Endpoint**: `/cms/lib/viewer_functions.php`
|
||||
**Método**: POST via getApiClient
|
||||
**Usado por**: `save_module`, `saveGeneralSection`, `check_module`, `list_modules`, `get_module`
|
||||
**Action**: `getModuleSchemas`
|
||||
**Payload via getCommonParams**:
|
||||
```javascript
|
||||
{
|
||||
action_ws: "getModuleSchemas",
|
||||
ids: [moduleId], // opcional, para un módulo específico
|
||||
full: 1 // opcional, para obtener contenido completo
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Verificar módulo
|
||||
**Endpoint**: `https://[website]/cms/lib/viewer_functions.php?action_ws=checkModuleCode`
|
||||
**Método**: POST
|
||||
**Usado por**: `check_module`
|
||||
**Payload**:
|
||||
```javascript
|
||||
{
|
||||
moduleName: string,
|
||||
vars: object // variables de prueba
|
||||
}
|
||||
```
|
||||
|
||||
## Categoría: Secciones Generales (saveLexicalData)
|
||||
|
||||
### 1. Guardar sección con contenido Twig/HTML
|
||||
**Endpoint**: `https://[website]/cms/lib/viewer_functions.php`
|
||||
**Método**: POST
|
||||
**Usado por**: `saveGeneralSection`
|
||||
**Action**: `saveLexicalData`
|
||||
**Payload**:
|
||||
```javascript
|
||||
{
|
||||
action_ws: 'saveLexicalData',
|
||||
token: credentials.token,
|
||||
tokenHash: credentials.tokenHash,
|
||||
content: string, // HTML parsed content
|
||||
rawDataSended: true,
|
||||
endPointFolder: string, // e.g., 'custom-productos'
|
||||
parserType: '2' | '0', // 2=Twig, 0=Acai
|
||||
aditionalFiles: [ // CSS, JS files
|
||||
{
|
||||
path: string,
|
||||
fileName: string,
|
||||
content: string
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Categoría: Registros (CRUD)
|
||||
|
||||
### 1. Crear/Actualizar registro
|
||||
**Endpoint**: `${CMS_URL}/admin.php`
|
||||
**Método**: POST
|
||||
**Usado por**: `create_or_update_record`
|
||||
**Content-Type**: `application/x-www-form-urlencoded`
|
||||
**Params**:
|
||||
```
|
||||
menu={tableName}
|
||||
_defaultAction=save
|
||||
num={recordId} // empty para crear
|
||||
type=
|
||||
preSaveTempId={timestamp}
|
||||
action=save
|
||||
{fieldname}={value} // campos del registro
|
||||
{fieldname}:year, :mon, etc // para campos date
|
||||
enlace={value}
|
||||
```
|
||||
|
||||
### 2. Listar registros
|
||||
**Endpoint**: `${CMS_URL}/admin.php?menu={tableName}&json=1&page={n}&keyword={q}`
|
||||
**Método**: GET
|
||||
**Usado por**: `list_table_records`
|
||||
**Headers**: `X-Acai-Token`, `X-Requested-With: XMLHttpRequest`
|
||||
|
||||
### 3. Eliminar registros
|
||||
**Endpoint**: `${CMS_URL}/admin.php`
|
||||
**Método**: POST
|
||||
**Usado por**: `delete_table_records`
|
||||
**Params**:
|
||||
```
|
||||
menu={tableName}
|
||||
_defaultAction=list
|
||||
page=1
|
||||
_advancedAction=eraseRecords
|
||||
_advancedActionSubmit=Ejecutar
|
||||
selectedRecords[]={id1}
|
||||
selectedRecords[]={id2}
|
||||
```
|
||||
|
||||
## Categoría: Archivos (saveFileBuilder, removeFileBuilder)
|
||||
|
||||
### 1. Escribir archivo
|
||||
**Endpoint**: `/cms/lib/viewer_functions.php`
|
||||
**Método**: POST via getApiClient
|
||||
**Usado por**: `write_file`
|
||||
**Action**: `saveFileBuilder`
|
||||
**Payload via getCommonParams**:
|
||||
```javascript
|
||||
{
|
||||
action_ws: "saveFileBuilder",
|
||||
path: string, // ej: '/modulos/mymodule/'
|
||||
fileName: string, // ej: 'style.css'
|
||||
content: string,
|
||||
rawDataSended: false,
|
||||
rootFolder: false
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Listar archivos (FTP)
|
||||
**Endpoint**: `/cms/lib/viewer_functions.php`
|
||||
**Método**: POST via getApiClient
|
||||
**Usado por**: `list_files`
|
||||
**Action**: `getFTPFiles`
|
||||
**Payload via getCommonParams**:
|
||||
```javascript
|
||||
{
|
||||
action_ws: "getFTPFiles",
|
||||
path: string // directorio a listar
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Eliminar archivo
|
||||
**Endpoint**: `/cms/lib/viewer_functions.php`
|
||||
**Método**: POST via getApiClient
|
||||
**Usado por**: `delete_file`
|
||||
**Action**: `removeFileBuilder`
|
||||
**Payload via getCommonParams**:
|
||||
```javascript
|
||||
{
|
||||
action_ws: "removeFileBuilder",
|
||||
path: string // ruta del archivo
|
||||
}
|
||||
```
|
||||
|
||||
## Categoría: Tablas (Database Schema)
|
||||
|
||||
### 1. Listar tablas (SaaS)
|
||||
**Endpoint**: `${SAAS_URL}`
|
||||
**Método**: POST
|
||||
**Usado por**: `list_tables`
|
||||
**Payload**:
|
||||
```javascript
|
||||
{
|
||||
action: 'getSchemaTables',
|
||||
type: 'acai'
|
||||
}
|
||||
```
|
||||
**Headers**: `Authorization: Bearer {token}`, `Content-Type: application/json`
|
||||
|
||||
### 2. Obtener esquema tabla (SaaS)
|
||||
**Endpoint**: `${SAAS_URL}`
|
||||
**Método**: POST
|
||||
**Usado por**: `get_table_schema`
|
||||
**Payload**:
|
||||
```javascript
|
||||
{
|
||||
action: 'getSchemaTables',
|
||||
type: 'acai'
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Actualizar esquema tabla (SaaS + CMS)
|
||||
**Endpoint SaaS**: `${SAAS_URL}` (PUT)
|
||||
**Método**: PUT
|
||||
**Usado por**: `update_table_schema`
|
||||
**Payload**:
|
||||
```javascript
|
||||
{
|
||||
action: "saveSchema",
|
||||
type: "acai",
|
||||
schema: object, // esquema completo o parcial
|
||||
dir: "",
|
||||
id: tableName
|
||||
}
|
||||
```
|
||||
|
||||
**Luego sincronizar en CMS**:
|
||||
**Endpoint CMS**: `/cms/lib/viewer_functions.php`
|
||||
**Método**: POST via getApiClient
|
||||
**Action**: `updateAllSchemas`
|
||||
**Payload via getCommonParams**:
|
||||
```javascript
|
||||
{
|
||||
action_ws: "updateAllSchemas",
|
||||
tokenHash: credentials.tokenHash
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Crear tabla
|
||||
**Endpoint**: `${CMS_URL}/admin.php`
|
||||
**Método**: POST
|
||||
**Usado por**: `create_table`
|
||||
**Params**:
|
||||
```
|
||||
menu=database
|
||||
_defaultAction=addTable_save
|
||||
type={multi|single|category|separador}
|
||||
preset=
|
||||
enlace={on|''}
|
||||
seo_metas={on|''}
|
||||
menuName={name}
|
||||
menuOrder={order}
|
||||
tableName={name}
|
||||
```
|
||||
|
||||
### 5. Eliminar tabla
|
||||
**Endpoint**: `${CMS_URL}/admin.php`
|
||||
**Método**: POST
|
||||
**Usado por**: `delete_table`
|
||||
**Params**:
|
||||
```
|
||||
menu=database
|
||||
action=editTable
|
||||
dropTable=1
|
||||
tableName={name}
|
||||
```
|
||||
|
||||
### 6. Editar campos tabla
|
||||
**Endpoint**: `${CMS_URL}/admin.php`
|
||||
**Método**: POST
|
||||
**Usado por**: `edit_table_field`
|
||||
**Params**:
|
||||
```
|
||||
menu=database
|
||||
_defaultAction=editTable
|
||||
editField=1
|
||||
tableName=cms_{tableName}
|
||||
save=1
|
||||
multipleFields={JSON.stringify(fieldArray)}
|
||||
```
|
||||
|
||||
### 7. Eliminar campo tabla
|
||||
**Endpoint**: `${CMS_URL}/admin.php`
|
||||
**Método**: POST
|
||||
**Usado por**: `delete_table_field`
|
||||
**Params**:
|
||||
```
|
||||
menu=database
|
||||
action=editTable
|
||||
editField=1
|
||||
tableName=cms_{tableName}
|
||||
fieldname={fieldname}
|
||||
deleteField=1
|
||||
```
|
||||
|
||||
### 8. Obtener templates tabla (general section)
|
||||
**Endpoint**: `/cms/lib/viewer_functions.php`
|
||||
**Método**: POST via getApiClient
|
||||
**Usado por**: `get_table_templates`
|
||||
**Action**: `getTableData`
|
||||
**Payload via getCommonParams**:
|
||||
```javascript
|
||||
{
|
||||
action_ws: "getTableData",
|
||||
menu: tableName
|
||||
}
|
||||
```
|
||||
|
||||
## Categoría: Media (Upload)
|
||||
|
||||
### 1. Subir imagen a campo
|
||||
**Endpoint**: `${CMS_URL}/lib/menus/modals/plupload/multiupload/upload.php?menu={table}&fieldName={field}&num={recordId}&preSaveTempId=`
|
||||
**Método**: POST (FormData)
|
||||
**Usado por**: `upload_record_image`
|
||||
**Form Fields**:
|
||||
```
|
||||
file={File buffer} // File object
|
||||
```
|
||||
|
||||
### 2. Listar uploads campo
|
||||
**Endpoint**: `${CMS_URL}/admin.php?menu={table}&action=uploadList&fieldName={field}&num={recordId}&preSaveTempId=&json=1`
|
||||
**Método**: GET
|
||||
**Usado por**: `list_record_uploads`
|
||||
**Headers**: `X-Acai-Token`
|
||||
|
||||
### 3. Reemplazar upload
|
||||
**Endpoint**: `${CMS_URL}/admin.php`
|
||||
**Método**: POST (FormData)
|
||||
**Usado por**: `replace_record_image`
|
||||
**Form Fields**:
|
||||
```
|
||||
_defaultAction=uploadModify
|
||||
menu={tableName}
|
||||
fieldName={fieldName}
|
||||
num={recordId}
|
||||
preSaveTempId=
|
||||
save=1
|
||||
uploadNums[]={uploadId}
|
||||
{uploadId}_file={File buffer}
|
||||
{uploadId}_name={originalFilePath}
|
||||
{uploadId}_alt={altText}
|
||||
action=uploadModify
|
||||
```
|
||||
|
||||
### 4. Eliminar upload
|
||||
**Endpoint**: `${CMS_URL}/admin.php?menu={table}&action=uploadErase&fieldName={field}&uploadNum={id}&num={recordId}&preSaveTempId=`
|
||||
**Método**: GET
|
||||
**Usado por**: `delete_record_upload`
|
||||
**Headers**: `X-Acai-Token`, `X-Requested-With: XMLHttpRequest`
|
||||
|
||||
## Patrones Comunes
|
||||
|
||||
### getApiClient Calls
|
||||
```javascript
|
||||
const client = getApiClient(extra.sessionId);
|
||||
const response = await client.post("/cms/lib/viewer_functions.php", getCommonParams(extra.sessionId, {
|
||||
action_ws: "actionName",
|
||||
// ... otros params
|
||||
}));
|
||||
```
|
||||
|
||||
### getCommonParams
|
||||
Agrega automáticamente:
|
||||
- token
|
||||
- tokenHash
|
||||
- website
|
||||
- session info
|
||||
|
||||
### Headers Recurrentes
|
||||
```javascript
|
||||
{
|
||||
"X-Acai-Token": credentials.token,
|
||||
"Content-Type": "application/json" | "application/x-www-form-urlencoded"
|
||||
}
|
||||
```
|
||||
|
||||
## Notas Importantes
|
||||
|
||||
1. **Construcción de URLs**: Algunos endpoints usan la URL base dinámicamente (`https://{website}/...`) mientras otros usan `CMS_URL` configurado.
|
||||
2. **Parámetros de formulario**: Algunos endpoints esperan URLSearchParams, otros JSON.
|
||||
3. **Token Auth**: Algunos usan `X-Acai-Token`, otros pasan token en payload.
|
||||
4. **Respuestas**: Varían entre `{success: true}`, `{result: true}`, o respuestas direc tas.
|
||||
270
mcp-server/tools/helpers/ERROR_HANDLING.md
Normal file
270
mcp-server/tools/helpers/ERROR_HANDLING.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# Error Handling System for Tools
|
||||
|
||||
Centralizado error handling para todas las herramientas MCP del servidor.
|
||||
|
||||
## Características
|
||||
|
||||
✅ **Manejo consistente de errores** - Todas las herramientas retornan el mismo formato
|
||||
✅ **Logging automático** - Todos los errores se registran en consola
|
||||
✅ **Validación de parámetros** - Validación requerida y de tipos
|
||||
✅ **Detección de errores API** - Identifica patrones comunes de error en respuestas
|
||||
✅ **Información contextual** - Cada error incluye el contexto de dónde ocurrió
|
||||
|
||||
## Funciones Disponibles
|
||||
|
||||
### `handleToolError(error, context, additionalInfo)`
|
||||
|
||||
Maneja cualquier error y retorna una respuesta formateada.
|
||||
|
||||
```javascript
|
||||
import { handleToolError } from "../helpers/errorHandler.js";
|
||||
|
||||
try {
|
||||
// tu código
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'my_tool', { userId: 123 });
|
||||
}
|
||||
```
|
||||
|
||||
**Retorna:**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "ECONNREFUSED",
|
||||
"message": "connect ECONNREFUSED 127.0.0.1:3000",
|
||||
"context": "my_tool",
|
||||
"userId": 123
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `handleApiResponse(data, context)`
|
||||
|
||||
Detecta errores en respuestas de API (busca patrones comunes).
|
||||
|
||||
```javascript
|
||||
const response = await axios.post(url, payload);
|
||||
|
||||
// Detecta automáticamente: error, Error, PHPSyntax, success: false, etc.
|
||||
const apiError = handleApiResponse(response.data, 'save_module');
|
||||
if (apiError) return apiError;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `validateRequired(params, requiredFields, context)`
|
||||
|
||||
Valida que los parámetros requeridos estén presentes.
|
||||
|
||||
```javascript
|
||||
const error = validateRequired(
|
||||
{ name: "Juan", email: "" },
|
||||
['name', 'email'],
|
||||
'create_user'
|
||||
);
|
||||
// error porque email está vacío
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `validateTypes(params, schema, context)`
|
||||
|
||||
Valida tipos de datos.
|
||||
|
||||
```javascript
|
||||
const error = validateTypes(
|
||||
{ age: "25", active: true },
|
||||
{ age: 'number', active: 'boolean' },
|
||||
'create_user'
|
||||
);
|
||||
// error porque age es string, no number
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `createValidator(requiredFields, typeSchema)`
|
||||
|
||||
Crea una función validadora reutilizable.
|
||||
|
||||
```javascript
|
||||
const validateUserInput = createValidator(
|
||||
['name', 'email'],
|
||||
{ age: 'number', active: 'boolean' }
|
||||
);
|
||||
|
||||
// Usar en múltiples lugares
|
||||
const error = validateUserInput(params, 'create_user');
|
||||
if (error) return error;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `withErrorHandling(handler, toolName)`
|
||||
|
||||
Envuelve un handler para manejar errores automáticamente.
|
||||
|
||||
```javascript
|
||||
const safeHandler = withErrorHandling(
|
||||
async (params, extra) => {
|
||||
// tu código
|
||||
},
|
||||
'my_tool'
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `safeJsonParse(jsonString, context)`
|
||||
|
||||
Parse JSON seguro con manejo de errores.
|
||||
|
||||
```javascript
|
||||
const result = safeJsonParse(jsonString, 'parse_config');
|
||||
if (!result.success) {
|
||||
// result.error contiene el error formateado
|
||||
return result.error;
|
||||
}
|
||||
const data = result.data;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Patrón Recomendado para Tools
|
||||
|
||||
```javascript
|
||||
import { z } from "zod";
|
||||
import axios from "axios";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import {
|
||||
handleToolError,
|
||||
handleApiResponse,
|
||||
validateRequired
|
||||
} from "../helpers/errorHandler.js";
|
||||
|
||||
export function registerMyTool(server) {
|
||||
server.tool(
|
||||
"my_tool",
|
||||
"Descripción de la herramienta",
|
||||
{
|
||||
param1: z.string().describe("Parámetro 1"),
|
||||
param2: z.number().describe("Parámetro 2"),
|
||||
},
|
||||
withAuth(async ({ param1, param2 }, extra) => {
|
||||
try {
|
||||
// 1. Validar parámetros requeridos
|
||||
const validationError = validateRequired(
|
||||
{ param1, param2 },
|
||||
['param1', 'param2'],
|
||||
'my_tool'
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
// 2. Obtener credenciales
|
||||
const credentials = getSessionCredentials(extra.sessionId);
|
||||
|
||||
// 3. Hacer llamada API
|
||||
const response = await axios.post(url, payload, {
|
||||
headers: { /* ... */ }
|
||||
});
|
||||
|
||||
// 4. Verificar respuesta de API
|
||||
const apiError = handleApiResponse(response.data, 'my_tool');
|
||||
if (apiError) return apiError;
|
||||
|
||||
// 5. Retornar resultado
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify(response.data, null, 2)
|
||||
}]
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
// Los errores se capturan y formatean automáticamente
|
||||
return handleToolError(error, 'my_tool', { param1, param2 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Errores Detectados Automáticamente
|
||||
|
||||
`handleApiResponse()` detecta estos patrones en respuestas:
|
||||
|
||||
- ✅ `data.error` o `data.Error`
|
||||
- ✅ `data.PHPSyntax` - Errores de sintaxis PHP
|
||||
- ✅ `data.success === false` - Campo success explícito
|
||||
- ✅ Strings con palabras clave: "error", "fatal", "undefined", "syntax"
|
||||
- ✅ Respuestas vacías o null
|
||||
|
||||
---
|
||||
|
||||
## Formato de Error Consistente
|
||||
|
||||
Todos los errores retornan este formato:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "ERROR_CODE",
|
||||
"message": "Mensaje descriptivo del error",
|
||||
"context": "nombre_del_tool",
|
||||
"...": "información adicional"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migración de Tools Existentes
|
||||
|
||||
Para actualizar un tool existente:
|
||||
|
||||
1. Importar funciones de error handler
|
||||
2. Reemplazar `try-catch` genérico con `handleToolError()`
|
||||
3. Agregar validación con `validateRequired()`
|
||||
4. Agregar `handleApiResponse()` después de llamadas API
|
||||
5. Pasar información contextual útil a `handleToolError()`
|
||||
|
||||
**Ejemplo antes:**
|
||||
```javascript
|
||||
try {
|
||||
// código
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: " + error.message }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Ejemplo después:**
|
||||
```javascript
|
||||
try {
|
||||
// código
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'my_tool', { extraInfo: value });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
Todos los errores se registran en stderr con contexto:
|
||||
|
||||
```
|
||||
[Tool Error - save_module] Cannot read property 'website' of undefined
|
||||
Stack: Error: Cannot read property 'website' of undefined
|
||||
at registerSaveModuleTool (/Users/...save.js:45:20)
|
||||
...
|
||||
```
|
||||
|
||||
Esto facilita debug y auditoría de errores en producción.
|
||||
587
mcp-server/tools/helpers/acaiHttpClient.js
Normal file
587
mcp-server/tools/helpers/acaiHttpClient.js
Normal file
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* Acai CMS HTTP Client
|
||||
*
|
||||
* Centralizado helper para todas las llamadas HTTP a Acai CMS.
|
||||
* Proporciona métodos consistentes para interactuar con:
|
||||
* - Admin panel (admin.php)
|
||||
* - Viewer functions API
|
||||
* - SaaS API
|
||||
* - File upload endpoints
|
||||
*
|
||||
* Ventajas:
|
||||
* - Consistencia en headers, manejo de errores, logging
|
||||
* - Reduce duplicación de código
|
||||
* - Facilita mantenimiento y debugging
|
||||
* - Centraliza URLs y configuración
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { getSessionCredentials } from '../../auth/index.js';
|
||||
import { CMS_URL } from '../../config/index.js';
|
||||
import { assertSafeCmsTarget } from '../../utils/cmsTargetSafety.js';
|
||||
|
||||
/**
|
||||
* AcaiHttpClient - Helper para solicitudes HTTP a Acai CMS
|
||||
*/
|
||||
export class AcaiHttpClient {
|
||||
static resolveCmsTarget(target) {
|
||||
const { publicUrl, apiUrl, forgeHost } = assertSafeCmsTarget(target, "AcaiHttpClient");
|
||||
const headers = {};
|
||||
if (forgeHost) {
|
||||
headers.Host = forgeHost;
|
||||
}
|
||||
|
||||
return {
|
||||
publicUrl,
|
||||
apiUrl,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
static buildViewerUrl(target, query = "") {
|
||||
const { apiUrl } = AcaiHttpClient.resolveCmsTarget(target);
|
||||
const baseUrl = apiUrl.endsWith("/") ? apiUrl.slice(0, -1) : apiUrl;
|
||||
return `${baseUrl}/cms/lib/viewer_functions.php${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
static buildViewerHeaders(target, extraHeaders = {}) {
|
||||
const { headers } = AcaiHttpClient.resolveCmsTarget(target);
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
...extraHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
static async postViewerAction(target, actionWs, payload, token, tokenHash, extraHeaders = {}, timeout = 30000) {
|
||||
const viewerUrl = AcaiHttpClient.buildViewerUrl(target, `action_ws=${actionWs}`);
|
||||
const body = {
|
||||
...payload,
|
||||
token,
|
||||
tokenHash,
|
||||
};
|
||||
return axios.post(viewerUrl, body, {
|
||||
headers: AcaiHttpClient.buildViewerHeaders(target, extraHeaders),
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a admin.php con URLSearchParams
|
||||
* @param {string} website - Website/domain (no usado, se usa CMS_URL del config)
|
||||
* @param {URLSearchParams} params - Parámetros del formulario
|
||||
* @param {string} token - Token Acai
|
||||
* @returns {Promise<Object>} Respuesta del servidor
|
||||
*/
|
||||
static async postAdminForm(website, params, token) {
|
||||
const cmsUrl = `${CMS_URL}/admin.php`;
|
||||
|
||||
try {
|
||||
console.error(`[AcaiHttpClient] postAdminForm - START: ${cmsUrl}`);
|
||||
const response = await axios.post(cmsUrl, params, {
|
||||
headers: {
|
||||
"X-Acai-Token": token,
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
console.error(`[AcaiHttpClient] postAdminForm - SUCCESS: ${cmsUrl} (${response.status})`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(`[AcaiHttpClient] postAdminForm - ERROR: ${cmsUrl} - ${error.message}`);
|
||||
if (error.response) {
|
||||
console.error(`[AcaiHttpClient] Response status: ${error.response.status}`);
|
||||
console.error(`[AcaiHttpClient] Response data:`, error.response.data?.substring ? error.response.data.substring(0, 200) : error.response.data);
|
||||
} else if (error.code) {
|
||||
console.error(`[AcaiHttpClient] Error code: ${error.code}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a admin.php con FormData (para uploads)
|
||||
* @param {string} website - Website/domain (no usado, se usa CMS_URL del config)
|
||||
* @param {FormData} formData - Datos del formulario
|
||||
* @param {string} token - Token Acai
|
||||
* @returns {Promise<Object>} Respuesta del servidor
|
||||
*/
|
||||
static async postAdminFormData(website, formData, token) {
|
||||
const cmsUrl = `${CMS_URL}/admin.php`;
|
||||
|
||||
try {
|
||||
console.error(`[AcaiHttpClient] postAdminFormData - START: ${cmsUrl}`);
|
||||
const response = await axios.post(cmsUrl, formData, {
|
||||
headers: {
|
||||
...formData.getHeaders(),
|
||||
"X-Acai-Token": token
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
console.error(`[AcaiHttpClient] postAdminFormData - SUCCESS: ${cmsUrl} (${response.status})`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(`[AcaiHttpClient] postAdminFormData - ERROR: ${cmsUrl} - ${error.message}`);
|
||||
if (error.response) {
|
||||
console.error(`[AcaiHttpClient] Response status: ${error.response.status}`);
|
||||
console.error(`[AcaiHttpClient] Response data:`, error.response.data?.substring ? error.response.data.substring(0, 200) : error.response.data);
|
||||
} else if (error.code) {
|
||||
console.error(`[AcaiHttpClient] Error code: ${error.code}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET a admin.php con query parameters
|
||||
* @param {string} website - Website/domain (no usado, se usa CMS_URL del config)
|
||||
* @param {URLSearchParams | string} params - Parámetros de query
|
||||
* @param {string} token - Token Acai
|
||||
* @returns {Promise<Object>} Respuesta del servidor
|
||||
*/
|
||||
static async getAdminQuery(website, params, token) {
|
||||
const cmsUrl = `${CMS_URL}/admin.php`;
|
||||
const queryString = params instanceof URLSearchParams
|
||||
? params.toString()
|
||||
: params;
|
||||
|
||||
try {
|
||||
console.error(`[AcaiHttpClient] getAdminQuery - START: ${cmsUrl}?${queryString.substring(0, 100)}`);
|
||||
const response = await axios.get(
|
||||
`${cmsUrl}?${queryString}`,
|
||||
{
|
||||
headers: {
|
||||
"X-Acai-Token": token,
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
console.error(`[AcaiHttpClient] getAdminQuery - SUCCESS: ${cmsUrl} (${response.status})`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(`[AcaiHttpClient] getAdminQuery - ERROR: ${cmsUrl} - ${error.message}`);
|
||||
if (error.response) {
|
||||
console.error(`[AcaiHttpClient] Response status: ${error.response.status}`);
|
||||
console.error(`[AcaiHttpClient] Response data:`, error.response.data?.substring ? error.response.data.substring(0, 200) : error.response.data);
|
||||
} else if (error.code) {
|
||||
console.error(`[AcaiHttpClient] Error code: ${error.code}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a viewer_functions.php vía getApiClient
|
||||
* Requiere llamar desde dentro de withAuth para tener acceso a getApiClient
|
||||
* @param {Object} client - cliente de axios (getApiClient)
|
||||
* @param {Object} payload - Payload con action_ws y otros parámetros
|
||||
* @returns {Promise<Object>} Respuesta del servidor
|
||||
*/
|
||||
static async postViewerFunctions(client, payload) {
|
||||
return client.post("/cms/lib/viewer_functions.php", payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a viewer_functions.php para saveLexicalData (secciones, contenido)
|
||||
* @param {string} web_url - URL base del sitio (ej: http://localhost:PORT)
|
||||
* @param {Object} credentials - {token, tokenHash}
|
||||
* @param {Object} data - Datos a guardar
|
||||
* @returns {Promise<Object>} Respuesta del servidor
|
||||
*/
|
||||
static async saveLexicalData(target, credentials, data) {
|
||||
const viewerUrl = AcaiHttpClient.buildViewerUrl(target);
|
||||
|
||||
const payload = {
|
||||
action_ws: 'saveLexicalData',
|
||||
token: credentials.token,
|
||||
tokenHash: credentials.tokenHash,
|
||||
rawDataSended: true,
|
||||
...data
|
||||
};
|
||||
|
||||
return axios.post(viewerUrl, payload, {
|
||||
headers: AcaiHttpClient.buildViewerHeaders(target)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST para generar módulo desde HTML
|
||||
* @param {Object} moduleData - Datos del módulo
|
||||
* @param {string} token - Token Acai
|
||||
* @returns {Promise<Object>} Respuesta del servidor
|
||||
*/
|
||||
static async generateModuleFromString(moduleData, token) {
|
||||
const cmsUrl = 'https://acai.cms.cocosolution.com/admin.php?menu=apartados&action=edit&generateModuleFromString=1';
|
||||
|
||||
return axios.post(cmsUrl, moduleData, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Acai-Token": token
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a viewer_functions para CMS API (insert, update, delete, get)
|
||||
* @param {string} web_url - URL base del sitio (ej: http://localhost:PORT)
|
||||
* @param {string} action - 'insert', 'update', 'delete', 'get'
|
||||
* @param {Object} payload - Datos de la operación
|
||||
* @param {string} token - Token Acai
|
||||
* @returns {Promise<Object>} Respuesta del servidor
|
||||
*/
|
||||
static async postCmsApi(target, action, payload, token, tokenHash) {
|
||||
const viewerUrl = AcaiHttpClient.buildViewerUrl(target, `action_ws=cmsApi&subaction=${action}`);
|
||||
|
||||
try {
|
||||
console.error(`[AcaiHttpClient] postCmsApi - START: ${action} on ${viewerUrl}`);
|
||||
console.error(`[AcaiHttpClient] Payload:`, JSON.stringify(payload).substring(0, 500));
|
||||
console.error(`[AcaiHttpClient] Token: ${token ? '****' + token.slice(-4) : 'No token provided'}`);
|
||||
|
||||
payload["token"] = token;
|
||||
payload["tokenHash"] = tokenHash;
|
||||
|
||||
const response = await axios.post(viewerUrl, payload, {
|
||||
headers: AcaiHttpClient.buildViewerHeaders(target, {
|
||||
"X-Acai-Token": token
|
||||
}),
|
||||
timeout: 30000
|
||||
});
|
||||
console.error(`[AcaiHttpClient] postCmsApi - SUCCESS: ${action} (${response.status})`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(`[AcaiHttpClient] postCmsApi - ERROR: ${action} on ${viewerUrl} - ${error.message}`);
|
||||
if (error.response) {
|
||||
console.error(`[AcaiHttpClient] Response status: ${error.response.status}`);
|
||||
console.error(`[AcaiHttpClient] Response data:`, error.response.data?.substring ? error.response.data.substring(0, 200) : error.response.data);
|
||||
} else if (error.code) {
|
||||
console.error(`[AcaiHttpClient] Error code: ${error.code}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a viewer_functions para checkModuleCode
|
||||
* @param {string} web_url - URL base del sitio (ej: http://localhost:PORT)
|
||||
* @param {string} token - Token Acai
|
||||
* @param {Object} data - {moduleName, vars}
|
||||
* @returns {Promise<Object>} Respuesta del servidor
|
||||
*/
|
||||
static async checkModuleCode(target, token, data) {
|
||||
const viewerUrl = AcaiHttpClient.buildViewerUrl(target, "action_ws=checkModuleCode");
|
||||
try {
|
||||
data["token"] = token;
|
||||
console.error(`[AcaiHttpClient] checkModuleCode - START: ${viewerUrl}`);
|
||||
const response = await axios.post(viewerUrl, data, {
|
||||
headers: AcaiHttpClient.buildViewerHeaders(target),
|
||||
timeout: 30000
|
||||
});
|
||||
console.error(`[AcaiHttpClient] checkModuleCode - SUCCESS: ${viewerUrl} (${response.status})`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(`[AcaiHttpClient] checkModuleCode - ERROR: ${viewerUrl}`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a viewer_functions para addModuleToRecord
|
||||
* @param {string} web_url - URL base del sitio (ej: http://localhost:PORT)
|
||||
* @param {string} token - Token Acai
|
||||
* @param {Object} data - {moduleName, vars}
|
||||
* @returns {Promise<Object>} Respuesta del servidor
|
||||
*/
|
||||
static async addModuleToRecord(target, token, tokenHash, data) {
|
||||
const viewerUrl = AcaiHttpClient.buildViewerUrl(target, "action_ws=addModuleToRecord");
|
||||
try {
|
||||
data["token"] = token;
|
||||
data["tokenHash"] = tokenHash;
|
||||
console.error(`[AcaiHttpClient] addModuleToRecord - START: ${viewerUrl}`);
|
||||
const response = await axios.post(viewerUrl, data, {
|
||||
headers: AcaiHttpClient.buildViewerHeaders(target),
|
||||
timeout: 30000
|
||||
});
|
||||
console.error(`[AcaiHttpClient] addModuleToRecord - SUCCESS: ${viewerUrl} (${response.status})`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(`[AcaiHttpClient] addModuleToRecord - ERROR: ${viewerUrl}`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a mcp_respond.php para setModuleConfigVars
|
||||
* @param {string} web_url - URL base del sitio (ej: http://localhost:PORT)
|
||||
* @param {string} token - Token Acai
|
||||
* @param {string} tokenHash - Token hash Acai
|
||||
* @param {Object} data - {tableName, recordNum, sectionId, vars}
|
||||
* @returns {Promise<Object>} Respuesta del servidor
|
||||
*/
|
||||
static async setModuleConfigVars(target, token, tokenHash, data) {
|
||||
const url = AcaiHttpClient.buildViewerUrl(target, "action_ws=setModuleConfigVars");
|
||||
try {
|
||||
data["token"] = token;
|
||||
data["tokenHash"] = tokenHash;
|
||||
console.error(`[AcaiHttpClient] setModuleConfigVars - START: ${url}`);
|
||||
const response = await axios.post(url, data, {
|
||||
headers: AcaiHttpClient.buildViewerHeaders(target),
|
||||
timeout: 30000
|
||||
});
|
||||
console.error(`[AcaiHttpClient] setModuleConfigVars - SUCCESS: ${url} (${response.status})`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(`[AcaiHttpClient] setModuleConfigVars - ERROR: ${url}`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a viewer_functions para getModuleConfigVars
|
||||
* @param {string} web_url - URL base del sitio (ej: http://localhost:PORT)
|
||||
* @param {string} token - Token Acai
|
||||
* @param {string} tokenHash - Token hash Acai
|
||||
* @param {Object} data - {tableName, recordNum, sectionId}
|
||||
* @returns {Promise<Object>} Respuesta del servidor
|
||||
*/
|
||||
static async getModuleConfigVars(target, token, tokenHash, data) {
|
||||
const url = AcaiHttpClient.buildViewerUrl(target, "action_ws=getModuleConfigVars");
|
||||
try {
|
||||
data["token"] = token;
|
||||
data["tokenHash"] = tokenHash;
|
||||
console.error(`[AcaiHttpClient] getModuleConfigVars - START: ${url}`);
|
||||
const response = await axios.post(url, data, {
|
||||
headers: AcaiHttpClient.buildViewerHeaders(target),
|
||||
timeout: 30000
|
||||
});
|
||||
console.error(`[AcaiHttpClient] getModuleConfigVars - SUCCESS: ${url} (${response.status})`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(`[AcaiHttpClient] getModuleConfigVars - ERROR: ${url}`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST para subir imagen a campo de registro
|
||||
* @param {string} website - Website/domain (no usado, se usa CMS_URL del config)
|
||||
* @param {string} tableName - Nombre de la tabla
|
||||
* @param {string} recordId - ID del registro
|
||||
* @param {string} fieldName - Nombre del campo
|
||||
* @param {FormData} formData - Datos del archivo
|
||||
* @param {string} token - Token Acai
|
||||
* @returns {Promise<Object>} Respuesta del servidor
|
||||
*/
|
||||
static async uploadRecordImage(website, tableName, recordId, fieldName, formData, token) {
|
||||
const uploadUrl = `${CMS_URL}/lib/menus/modals/plupload/multiupload/upload.php?menu=${tableName}&fieldName=${fieldName}&num=${recordId}&preSaveTempId=`;
|
||||
|
||||
return axios.post(uploadUrl, formData, {
|
||||
headers: {
|
||||
...formData.getHeaders(),
|
||||
"X-Acai-Token": token
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a SaaS API para guardar esquema
|
||||
* @param {Object} payload - {action, type, schema, dir, id, ...}
|
||||
* @param {string} token - Token autenticación
|
||||
* @returns {Promise<Object>} Respuesta del servidor
|
||||
*/
|
||||
static async saasPostRequest(payload, token) {
|
||||
const SAAS_URL = 'https://ws.cocosolution.com/api/schemas/';
|
||||
|
||||
try {
|
||||
console.error(`[AcaiHttpClient] saasPostRequest - START: ${SAAS_URL} (action: ${payload.action})`);
|
||||
const response = await axios.post(SAAS_URL, payload, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
console.error(`[AcaiHttpClient] saasPostRequest - SUCCESS: ${SAAS_URL} (${response.status})`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(`[AcaiHttpClient] saasPostRequest - ERROR: ${SAAS_URL} - ${error.message}`);
|
||||
if (error.response) {
|
||||
console.error(`[AcaiHttpClient] Response status: ${error.response.status}`);
|
||||
console.error(`[AcaiHttpClient] Response data:`, error.response.data?.substring ? error.response.data.substring(0, 200) : error.response.data);
|
||||
} else if (error.code) {
|
||||
console.error(`[AcaiHttpClient] Error code: ${error.code}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT a SaaS API para actualizar esquema
|
||||
* @param {Object} payload - {action, type, schema, dir, id}
|
||||
* @param {string} token - Token autenticación
|
||||
* @returns {Promise<Object>} Respuesta del servidor
|
||||
*/
|
||||
static async saasPutRequest(payload, token) {
|
||||
const SAAS_URL = 'https://ws.cocosolution.com/api/schemas/';
|
||||
|
||||
try {
|
||||
console.error(`[AcaiHttpClient] saasPutRequest - START: ${SAAS_URL} (action: ${payload.action})`);
|
||||
const response = await axios.put(SAAS_URL, payload, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
console.error(`[AcaiHttpClient] saasPutRequest - SUCCESS: ${SAAS_URL} (${response.status})`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(`[AcaiHttpClient] saasPutRequest - ERROR: ${SAAS_URL} - ${error.message}`);
|
||||
if (error.response) {
|
||||
console.error(`[AcaiHttpClient] Response status: ${error.response.status}`);
|
||||
console.error(`[AcaiHttpClient] Response data:`, error.response.data?.substring ? error.response.data.substring(0, 200) : error.response.data);
|
||||
} else if (error.code) {
|
||||
console.error(`[AcaiHttpClient] Error code: ${error.code}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper para construir parámetros comunes de formulario
|
||||
*/
|
||||
export class FormParamsBuilder {
|
||||
static buildRecordSaveParams(tableName, recordId, fields, enlace) {
|
||||
const params = new URLSearchParams();
|
||||
params.append('menu', tableName);
|
||||
params.append('_defaultAction', 'save');
|
||||
params.append('num', recordId ? String(recordId) : '');
|
||||
params.append('type', '');
|
||||
params.append('preSaveTempId', Date.now().toString());
|
||||
params.append('action=save', 'Guardar');
|
||||
|
||||
// Agregar todos los campos
|
||||
for (const [fieldName, value] of Object.entries(fields)) {
|
||||
if (fieldName === 'enlace') continue;
|
||||
|
||||
if (value !== null && value !== undefined) {
|
||||
const strValue = String(value);
|
||||
params.append(fieldName, strValue);
|
||||
|
||||
// Detectar y descomponer fechas
|
||||
const dateRegex = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/;
|
||||
const match = strValue.match(dateRegex);
|
||||
if (match) {
|
||||
params.append(`${fieldName}:year`, match[1]);
|
||||
params.append(`${fieldName}:mon`, match[2]);
|
||||
params.append(`${fieldName}:day`, match[3]);
|
||||
params.append(`${fieldName}:hour24`, match[4]);
|
||||
params.append(`${fieldName}:min`, match[5]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
params.append('enlace', enlace);
|
||||
return params;
|
||||
}
|
||||
|
||||
static buildDeleteRecordsParams(tableName, recordIds) {
|
||||
const params = new URLSearchParams();
|
||||
params.append('menu', tableName);
|
||||
params.append('_defaultAction', 'list');
|
||||
params.append('page', '1');
|
||||
params.append('_advancedAction', 'eraseRecords');
|
||||
params.append('_advancedActionSubmit', 'Ejecutar');
|
||||
|
||||
recordIds.forEach(id => {
|
||||
params.append('selectedRecords[]', String(id));
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper para construir URLs de query
|
||||
*/
|
||||
export class QueryParamsBuilder {
|
||||
static buildListRecordsQuery(tableName, page, keyword) {
|
||||
const params = new URLSearchParams({
|
||||
menu: tableName,
|
||||
json: "1"
|
||||
});
|
||||
if (page) params.append("page", String(page));
|
||||
if (keyword) params.append("keyword", keyword);
|
||||
return params;
|
||||
}
|
||||
|
||||
static buildListUploadsQuery(tableName, recordId, fieldName) {
|
||||
return new URLSearchParams({
|
||||
menu: tableName,
|
||||
action: 'uploadList',
|
||||
fieldName: fieldName,
|
||||
num: recordId,
|
||||
preSaveTempId: '',
|
||||
json: '1'
|
||||
});
|
||||
}
|
||||
|
||||
static buildDeleteUploadQuery(tableName, recordId, fieldName, uploadId) {
|
||||
return new URLSearchParams({
|
||||
menu: tableName,
|
||||
action: 'uploadErase',
|
||||
fieldName: fieldName,
|
||||
uploadNum: uploadId,
|
||||
num: recordId,
|
||||
preSaveTempId: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default AcaiHttpClient;
|
||||
6
mcp-server/tools/helpers/authSchema.js
Normal file
6
mcp-server/tools/helpers/authSchema.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Auth parameters helper.
|
||||
* In stdio mode, credentials come from environment variables — no inline params needed.
|
||||
* withAuthParams just passes through the schema unchanged.
|
||||
*/
|
||||
export const withAuthParams = (schema) => schema;
|
||||
215
mcp-server/tools/helpers/errorHandler.js
Normal file
215
mcp-server/tools/helpers/errorHandler.js
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Centralized error handling for tools
|
||||
* Provides consistent error responses and logging
|
||||
*/
|
||||
|
||||
/**
|
||||
* Handle and format tool errors
|
||||
* @param {Error|string} error - The error object or message
|
||||
* @param {string} context - Context where the error occurred (e.g., "save_module", "create_record")
|
||||
* @param {Object} additionalInfo - Additional information to include in response
|
||||
* @returns {Object} Formatted error response
|
||||
*/
|
||||
export function handleToolError(error, context = "unknown", additionalInfo = {}) {
|
||||
// Log error to console
|
||||
console.error(`[Tool Error - ${context}]`, error instanceof Error ? error.message : error);
|
||||
if (error instanceof Error && error.stack) {
|
||||
console.error(`Stack:`, error.stack);
|
||||
}
|
||||
|
||||
// Extract error message
|
||||
let errorMessage = error instanceof Error ? error.message : String(error);
|
||||
let errorCode = "UNKNOWN_ERROR";
|
||||
let statusCode = 500;
|
||||
|
||||
// Handle specific error types
|
||||
if (error.response) {
|
||||
// Axios error with response
|
||||
statusCode = error.response.status || 500;
|
||||
errorMessage = error.response.data?.message ||
|
||||
error.response.data?.error ||
|
||||
errorMessage;
|
||||
errorCode = `HTTP_${statusCode}`;
|
||||
} else if (error.code) {
|
||||
// Error with code (like ENOTFOUND, ECONNREFUSED, etc.)
|
||||
errorCode = error.code;
|
||||
errorMessage = `${error.code}: ${errorMessage}`;
|
||||
}
|
||||
|
||||
// Return formatted error response
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: {
|
||||
code: errorCode,
|
||||
message: errorMessage,
|
||||
context: context,
|
||||
...additionalInfo
|
||||
}
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API response errors (when response contains error indication)
|
||||
* @param {Object} data - Response data from API
|
||||
* @param {string} context - Context where error occurred
|
||||
* @returns {Object|null} Error response or null if no error
|
||||
*/
|
||||
export function handleApiResponse(data, context = "unknown") {
|
||||
// Check for common error patterns in Acai CMS responses
|
||||
/*if (!data) {
|
||||
return handleToolError("Empty response from API", context, { details: "API returned null or undefined" });
|
||||
}*/
|
||||
|
||||
// PHP/Acai error responses typically have error field or PHPSyntax errors
|
||||
if (data.error || data.Error) {
|
||||
return handleToolError(data.error || data.Error, context, { details: data });
|
||||
}
|
||||
|
||||
if (data.PHPSyntax) {
|
||||
return handleToolError(`PHP Syntax Error: ${data.PHPSyntax}`, context, { details: data });
|
||||
}
|
||||
|
||||
// If it's a string response with error indicators
|
||||
if (typeof data === 'string' && data.trim().length > 0) {
|
||||
// Check for common error patterns
|
||||
if (data.toLowerCase().includes('error') ||
|
||||
data.toLowerCase().includes('fatal') ||
|
||||
data.toLowerCase().includes('undefined') ||
|
||||
data.toLowerCase().includes('syntax')) {
|
||||
return handleToolError(data, context, { details: "API returned error string" });
|
||||
}
|
||||
}
|
||||
|
||||
// If success field exists and is false
|
||||
if (data.success === false) {
|
||||
return handleToolError(data.message || "API returned success: false", context, { details: data });
|
||||
}
|
||||
|
||||
// No error detected
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate required parameters
|
||||
* @param {Object} params - Parameters object
|
||||
* @param {string[]} requiredFields - Array of required field names
|
||||
* @param {string} context - Context where validation occurs
|
||||
* @returns {Object|null} Error response or null if all valid
|
||||
*/
|
||||
export function validateRequired(params, requiredFields, context = "unknown") {
|
||||
const missingFields = [];
|
||||
|
||||
requiredFields.forEach(field => {
|
||||
const value = params[field];
|
||||
if (value === null || value === undefined ||
|
||||
(typeof value === 'string' && value.trim() === '')) {
|
||||
missingFields.push(field);
|
||||
}
|
||||
});
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
return handleToolError(
|
||||
`Missing required parameters: ${missingFields.join(', ')}`,
|
||||
context,
|
||||
{ requiredFields, missingFields }
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate parameter types
|
||||
* @param {Object} params - Parameters object
|
||||
* @param {Object} schema - Schema of expected types {fieldName: 'string'|'number'|'boolean'|'array'|'object'}
|
||||
* @param {string} context - Context where validation occurs
|
||||
* @returns {Object|null} Error response or null if all valid
|
||||
*/
|
||||
export function validateTypes(params, schema, context = "unknown") {
|
||||
const typeErrors = [];
|
||||
|
||||
for (const [field, expectedType] of Object.entries(schema)) {
|
||||
const value = params[field];
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
continue; // Skip optional fields that are not provided
|
||||
}
|
||||
|
||||
let actualType = typeof value;
|
||||
if (Array.isArray(value)) actualType = 'array';
|
||||
if (value instanceof Date) actualType = 'date';
|
||||
|
||||
if (actualType !== expectedType) {
|
||||
typeErrors.push(`${field}: expected ${expectedType}, got ${actualType}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeErrors.length > 0) {
|
||||
return handleToolError(
|
||||
`Type validation failed: ${typeErrors.join('; ')}`,
|
||||
context,
|
||||
{ typeErrors }
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse JSON with error handling
|
||||
* @param {string} jsonString - JSON string to parse
|
||||
* @param {string} context - Context where parsing occurs
|
||||
* @returns {Object} Parsed object or error response object
|
||||
*/
|
||||
export function safeJsonParse(jsonString, context = "unknown") {
|
||||
try {
|
||||
return { success: true, data: JSON.parse(jsonString) };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: handleToolError(error, `${context} - JSON parsing`, { input: jsonString.substring(0, 100) })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a validation middleware for tools
|
||||
* @param {string[]} requiredFields - Required parameter names
|
||||
* @param {Object} typeSchema - Type validation schema
|
||||
* @returns {Function} Middleware function
|
||||
*/
|
||||
export function createValidator(requiredFields = [], typeSchema = {}) {
|
||||
return function validateInput(params, context = "unknown") {
|
||||
// Check required fields
|
||||
const requiredError = validateRequired(params, requiredFields, context);
|
||||
if (requiredError) return requiredError;
|
||||
|
||||
// Check types
|
||||
const typeError = validateTypes(params, typeSchema, context);
|
||||
if (typeError) return typeError;
|
||||
|
||||
return null; // No errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a tool handler with automatic error handling
|
||||
* @param {Function} handler - The tool handler function
|
||||
* @param {string} toolName - Name of the tool for logging
|
||||
* @returns {Function} Wrapped handler
|
||||
*/
|
||||
export function withErrorHandling(handler, toolName = "unknown") {
|
||||
return async (params, extra) => {
|
||||
try {
|
||||
return await handler(params, extra);
|
||||
} catch (error) {
|
||||
return handleToolError(error, toolName);
|
||||
}
|
||||
};
|
||||
}
|
||||
102
mcp-server/tools/helpers/fileBuilder.js
Normal file
102
mcp-server/tools/helpers/fileBuilder.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import axios from "axios";
|
||||
|
||||
/**
|
||||
* Helper to save files using saveFileBuilder action
|
||||
* Used by multiple tools (save.js, saveGeneralSection.js, write.js, etc.)
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.web_url - URL base del sitio (ej: http://localhost:PORT)
|
||||
* @param {string} params.token - Session token
|
||||
* @param {string} params.tokenHash - Token hash
|
||||
* @param {string} params.path - Folder path (e.g., '/modulos/mymodule/')
|
||||
* @param {string} params.fileName - File name (e.g., 'script.js', 'style.css')
|
||||
* @param {string} params.content - File content
|
||||
* @returns {Promise<Object>} Response from the API
|
||||
*/
|
||||
export async function saveFileBuilder({
|
||||
web_url,
|
||||
token,
|
||||
tokenHash,
|
||||
path,
|
||||
fileName,
|
||||
content,
|
||||
rawDataSended = true
|
||||
}) {
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const viewerUrl = web_url + '/cms/lib/viewer_functions.php';
|
||||
|
||||
const payload = {
|
||||
action_ws: 'saveFileBuilder',
|
||||
token: token,
|
||||
tokenHash: tokenHash,
|
||||
fileName: fileName,
|
||||
content: content,
|
||||
rawDataSended: rawDataSended,
|
||||
rootFolder: false,
|
||||
path: path
|
||||
};
|
||||
|
||||
console.error(`[saveFileBuilder] URL: ${viewerUrl}`);
|
||||
console.error(`[saveFileBuilder] Path: ${path}`);
|
||||
console.error(`[saveFileBuilder] Content length: ${content.length} chars`);
|
||||
|
||||
try {
|
||||
const response = await axios.post(viewerUrl, payload, {
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
|
||||
console.error(`[saveFileBuilder] Response for ${fileName}:`, JSON.stringify(response.data, null, 2));
|
||||
|
||||
return {
|
||||
success: response.data.success || false,
|
||||
message: response.data.message || (response.data.success ? 'OK' : 'Error'),
|
||||
data: response.data
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[saveFileBuilder] Error saving ${fileName}:`, error.message);
|
||||
return {
|
||||
success: false,
|
||||
message: `Error saving ${fileName}: ${error.message}`,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to save multiple files at once
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.web_url - URL base del sitio (ej: http://localhost:PORT)
|
||||
* @param {string} params.token - Session token
|
||||
* @param {string} params.tokenHash - Token hash
|
||||
* @param {string} params.path - Folder path (e.g., '/modulos/mymodule/')
|
||||
* @param {Object} params.files - Object with fileName: content pairs
|
||||
* @returns {Promise<Object>} Results for each file
|
||||
*/
|
||||
export async function saveMultipleFiles({
|
||||
web_url,
|
||||
token,
|
||||
tokenHash,
|
||||
path,
|
||||
files
|
||||
}) {
|
||||
const results = {};
|
||||
|
||||
for (const [fileName, content] of Object.entries(files)) {
|
||||
if (content) {
|
||||
results[fileName] = await saveFileBuilder({
|
||||
web_url,
|
||||
token,
|
||||
tokenHash,
|
||||
path,
|
||||
fileName,
|
||||
content
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
24
mcp-server/tools/index.js
Normal file
24
mcp-server/tools/index.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { registerModuleTools } from './modules/index.js';
|
||||
import { registerTableTools } from './tables/index.js';
|
||||
import { registerRecordTools } from './records/index.js';
|
||||
import { registerMediaTools } from './media/index.js';
|
||||
import { registerAuthTools } from './auth/index.js';
|
||||
import { registerRemoteGitTools } from './remote_git/index.js';
|
||||
import { registerNavigationTools } from './navigation/index.js';
|
||||
import { registerProjectTools } from './project/index.js';
|
||||
import { registerFileTools } from './files/index.js';
|
||||
|
||||
/**
|
||||
* Register all tools on the MCP server
|
||||
*/
|
||||
export function registerTools(server) {
|
||||
registerModuleTools(server);
|
||||
registerTableTools(server);
|
||||
registerRecordTools(server);
|
||||
registerMediaTools(server);
|
||||
registerAuthTools(server);
|
||||
registerRemoteGitTools(server);
|
||||
registerNavigationTools(server);
|
||||
registerProjectTools(server);
|
||||
registerFileTools(server);
|
||||
}
|
||||
213
mcp-server/tools/media/generateImage.js
Normal file
213
mcp-server/tools/media/generateImage.js
Normal file
@@ -0,0 +1,213 @@
|
||||
import { z } from "zod";
|
||||
import axios from "axios";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import sharp from "sharp";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleToolError } from "../helpers/errorHandler.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
|
||||
// --- Verificación de créditos y reporte de uso ---
|
||||
const WS_BASE = "https://ws.cocosolution.com/api/handler_acaicode.php";
|
||||
// Precios Gemini 2.5 Flash: input $0.15/1M tokens, output $0.60/1M tokens
|
||||
function calcCost(usageMetadata) {
|
||||
const input = usageMetadata?.promptTokenCount || 0;
|
||||
const output = usageMetadata?.candidatesTokenCount || 0;
|
||||
return Math.round(((input * 0.15 + output * 0.60) / 1_000_000) * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
function getAcaiToken() {
|
||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
||||
if (!projectDir) return null;
|
||||
try {
|
||||
const acaiFile = path.join(projectDir, ".acai");
|
||||
const data = JSON.parse(fs.readFileSync(acaiFile, "utf-8"));
|
||||
return data.token || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function checkCredits() {
|
||||
const token = getAcaiToken();
|
||||
if (!token) return false; // Si no hay token, no bloquear
|
||||
const testParam = process.env.STRIPE_MODE === "test" ? "&test" : "";
|
||||
try {
|
||||
const resp = await axios.put(`${WS_BASE}?action=getUsageLimits${testParam}`, {}, {
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
timeout: 10000,
|
||||
});
|
||||
return resp.data?.data?.exceeded === true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function reportImageUsage(usageMetadata, model) {
|
||||
const token = getAcaiToken();
|
||||
if (!token) return;
|
||||
const testParam = process.env.STRIPE_MODE === "test" ? "&test" : "";
|
||||
const cost = calcCost(usageMetadata);
|
||||
const payload = {
|
||||
action: "reportUsage",
|
||||
model: model || "gemini-2.5-flash-image",
|
||||
input_tokens: usageMetadata?.promptTokenCount || 0,
|
||||
output_tokens: usageMetadata?.candidatesTokenCount || 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
cost_usd: cost,
|
||||
session_id: "",
|
||||
};
|
||||
// Fire and forget
|
||||
axios.put(`${WS_BASE}?action=reportUsage${testParam}`, payload, {
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
timeout: 10000,
|
||||
}).then(resp => {
|
||||
if (resp.data?.success) console.error(`[generate_image] Usage reported: ${model} cost=$${cost}`);
|
||||
else console.error(`[generate_image] Usage report failed:`, resp.data);
|
||||
}).catch(err => console.error(`[generate_image] Usage report error:`, err.message));
|
||||
}
|
||||
|
||||
export function registerGenerateImageTool(server) {
|
||||
server.tool(
|
||||
"generate_image",
|
||||
`Generate an AI image and save it to the project's uploads folder. Returns preview URLs plus the recommended upload URL for upload_record_image. In Forge environments, prefer uploadUrl (or fullUrl if uploadUrl is absent) over dockerUrl when assigning the image to a record field.`,
|
||||
withAuthParams({
|
||||
prompt: z.string().describe("Description of the image to generate"),
|
||||
width: z.number().optional().describe("Image width in pixels (default: 1024)"),
|
||||
height: z.number().optional().describe("Image height in pixels (default: 1024)"),
|
||||
style: z.string().optional().describe("Image style hint to add to prompt (e.g., 'photographic', 'digital-art', 'minimalist')"),
|
||||
fileName: z.string().optional().describe("Custom filename (without extension). If not provided, auto-generated."),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ prompt, width = 1024, height = 1024, style, fileName }, extra) => {
|
||||
try {
|
||||
const nanoBananaApiKey = process.env.NANO_BANANA_API_KEY;
|
||||
if (!nanoBananaApiKey) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: NANO_BANANA_API_KEY not set." }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
||||
if (!projectDir) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: ACAI_PROJECT_DIR not set." }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Verificar créditos antes de generar
|
||||
const exceeded = await checkCredits();
|
||||
if (exceeded) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: No te quedan créditos. Mejora tu plan para seguir usando el asistente." }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Build prompt with style hint
|
||||
const fullPrompt = style ? `${prompt}. Style: ${style}` : prompt;
|
||||
|
||||
// Generate image via Google Gemini
|
||||
const geminiModel = process.env.NANO_BANANA_MODEL || "gemini-2.5-flash-image";
|
||||
const apiUrl = process.env.NANO_BANANA_URL ||
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent`;
|
||||
|
||||
const generateResponse = await axios.post(
|
||||
apiUrl,
|
||||
{
|
||||
contents: [{ parts: [{ text: fullPrompt }] }],
|
||||
generationConfig: {
|
||||
responseModalities: ["TEXT", "IMAGE"],
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"x-goog-api-key": nanoBananaApiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 120000,
|
||||
validateStatus: (status) => status < 500,
|
||||
}
|
||||
);
|
||||
|
||||
// Extract image from response
|
||||
let imageBuffer = null;
|
||||
if (generateResponse.data.candidates?.[0]?.content?.parts) {
|
||||
for (const part of generateResponse.data.candidates[0].content.parts) {
|
||||
if (part.inlineData?.data) {
|
||||
imageBuffer = Buffer.from(part.inlineData.data, "base64");
|
||||
break;
|
||||
}
|
||||
if (part.text?.startsWith("data:image")) {
|
||||
const match = part.text.match(/data:image\/[^;]+;base64,(.+)/);
|
||||
if (match) {
|
||||
imageBuffer = Buffer.from(match[1], "base64");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!imageBuffer) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Error: Could not extract image from API response. Status: ${generateResponse.status}. Response: ${JSON.stringify(generateResponse.data).substring(0, 1000)}`
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Compress to JPEG
|
||||
const originalSize = imageBuffer.length;
|
||||
try {
|
||||
imageBuffer = await sharp(imageBuffer)
|
||||
.jpeg({ quality: 85 })
|
||||
.toBuffer();
|
||||
console.error(`[generate_image] Compressed: ${Math.round(originalSize / 1024)}KB → ${Math.round(imageBuffer.length / 1024)}KB`);
|
||||
} catch (e) {
|
||||
console.error(`[generate_image] Compression failed, using original:`, e.message);
|
||||
}
|
||||
|
||||
// Save to cms/uploads/generated/
|
||||
const uploadsDir = path.join(projectDir, "cms", "uploads", "generated");
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
|
||||
const safeName = fileName
|
||||
? fileName.replace(/[^\w\-]/g, "_") + ".jpg"
|
||||
: `generated-${Date.now()}.jpg`;
|
||||
const filePath = path.join(uploadsDir, safeName);
|
||||
fs.writeFileSync(filePath, imageBuffer);
|
||||
|
||||
const relativePath = `cms/uploads/generated/${safeName}`;
|
||||
const dockerUrl = `http://localhost/${relativePath}`;
|
||||
|
||||
// Reportar uso (fire and forget)
|
||||
reportImageUsage(generateResponse.data.usageMetadata, geminiModel);
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
const fullUrl = credentials.web_url ? `${credentials.web_url}/${relativePath}` : dockerUrl;
|
||||
const uploadUrl = credentials.web_url ? fullUrl : dockerUrl;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
prompt: fullPrompt,
|
||||
fileName: safeName,
|
||||
filePath,
|
||||
relativePath,
|
||||
dockerUrl,
|
||||
fullUrl,
|
||||
uploadUrl,
|
||||
size: `${Math.round(imageBuffer.length / 1024)}KB`,
|
||||
note: `Image saved. To assign it with upload_record_image, use imageUrl="${uploadUrl}". dockerUrl is mainly for local preview/debugging.`,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, "generate_image", { prompt });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
9
mcp-server/tools/media/index.js
Normal file
9
mcp-server/tools/media/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { registerUploadRecordImageTool } from './upload.js';
|
||||
import { registerUploadImageToAssetsTool } from './uploadImageToAssets.js';
|
||||
import { registerGenerateImageTool } from './generateImage.js';
|
||||
|
||||
export function registerMediaTools(server) {
|
||||
registerUploadRecordImageTool(server);
|
||||
registerUploadImageToAssetsTool(server);
|
||||
registerGenerateImageTool(server);
|
||||
}
|
||||
294
mcp-server/tools/media/upload.js
Normal file
294
mcp-server/tools/media/upload.js
Normal file
@@ -0,0 +1,294 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
||||
|
||||
/**
|
||||
* Helper: POST to mcp_respond.php via viewer_functions.php
|
||||
*/
|
||||
async function mcpPost(target, actionWs, payload, token, tokenHash) {
|
||||
return AcaiHttpClient.postViewerAction(
|
||||
target,
|
||||
actionWs,
|
||||
payload,
|
||||
token,
|
||||
tokenHash,
|
||||
{},
|
||||
60000
|
||||
);
|
||||
}
|
||||
|
||||
export function registerUploadRecordImageTool(server) {
|
||||
server.tool(
|
||||
"upload_record_image",
|
||||
"Upload an image to a specific record field in Acai CMS. Downloads the image from a URL and uploads it. Table names are WITHOUT the 'cms_' prefix. The recordId is the 'num' primary key, never 'id'. If the URL came from generate_image, prefer uploadUrl (or fullUrl) over dockerUrl in Forge environments.",
|
||||
withAuthParams({
|
||||
tableName: z.string().describe("Table name without 'cms_' prefix (e.g., 'productos')"),
|
||||
recordId: z.string().describe("Record 'num' (primary key)"),
|
||||
fieldName: z.string().describe("Field name (e.g., 'galeria_imagenes')"),
|
||||
imageUrl: z.string().describe("URL of the image to upload"),
|
||||
alt: z.string().optional().describe("Alt text for the image (optional)"),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ tableName, recordId, fieldName, imageUrl, alt = "" }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired(
|
||||
{ tableName, recordId, fieldName, imageUrl },
|
||||
['tableName', 'recordId', 'fieldName', 'imageUrl'],
|
||||
'upload_record_image'
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
|
||||
// Upload via mcp_respond.php uploadRecordImage (sends imageUrl, PHP downloads it)
|
||||
const response = await mcpPost(
|
||||
credentials,
|
||||
"uploadRecordImage",
|
||||
{ tableName, recordId, fieldName, imageUrl, alt },
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
);
|
||||
|
||||
const apiError = handleApiResponse(response.data, 'upload_record_image');
|
||||
if (apiError) return apiError;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: "Image uploaded successfully",
|
||||
tableName,
|
||||
recordId,
|
||||
fieldName,
|
||||
...response.data
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'upload_record_image', { tableName, recordId, fieldName });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"list_record_uploads",
|
||||
"List all uploaded files in a specific upload field of a record. Table names are WITHOUT the 'cms_' prefix. The recordId is the 'num' primary key, never 'id'.",
|
||||
withAuthParams({
|
||||
tableName: z.string().describe("Table name without 'cms_' prefix (e.g., 'noticias')"),
|
||||
recordId: z.string().describe("Record 'num' (primary key)"),
|
||||
fieldName: z.string().describe("Upload field name (e.g., 'imagen_destacada')"),
|
||||
}),
|
||||
{ readOnlyHint: true, destructiveHint: false },
|
||||
withAuth(async ({ tableName, recordId, fieldName }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired(
|
||||
{ tableName, recordId, fieldName },
|
||||
['tableName', 'recordId', 'fieldName'],
|
||||
'list_record_uploads'
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
|
||||
const response = await mcpPost(
|
||||
credentials,
|
||||
"listRecordUploads",
|
||||
{ tableName, recordId, fieldName },
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
);
|
||||
|
||||
const apiError = handleApiResponse(response.data, 'list_record_uploads');
|
||||
if (apiError) return apiError;
|
||||
|
||||
const uploads = (response.data.data || []).map(upload => ({
|
||||
uploadId: upload.num,
|
||||
filePath: upload.filePath,
|
||||
urlPath: upload.urlPath,
|
||||
fileName: (upload.filePath || "").split('/').pop(),
|
||||
altText: upload.info1 || upload.alt || "",
|
||||
width: upload.width,
|
||||
height: upload.height,
|
||||
filesize: upload.filesize,
|
||||
createdTime: upload.createdTime,
|
||||
order: upload.order
|
||||
}));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
tableName,
|
||||
recordId,
|
||||
fieldName,
|
||||
uploadsCount: uploads.length,
|
||||
uploads,
|
||||
note: "Use uploadId (num field) to replace or delete a specific file"
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'list_record_uploads', { tableName, recordId, fieldName });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"replace_record_image",
|
||||
"Replace an existing image in an upload field. Downloads a new image from URL and replaces the specified upload. Use list_record_uploads to get the uploadId first. Table names are WITHOUT the 'cms_' prefix.",
|
||||
withAuthParams({
|
||||
tableName: z.string().describe("Table name without 'cms_' prefix"),
|
||||
recordId: z.string().describe("Record 'num' (primary key)"),
|
||||
fieldName: z.string().describe("Upload field name"),
|
||||
uploadId: z.string().describe("Upload ID to replace (get from list_record_uploads)"),
|
||||
imageUrl: z.string().describe("URL of the new image to upload"),
|
||||
alt: z.string().optional().describe("Alt text for the image (optional)"),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ tableName, recordId, fieldName, uploadId, imageUrl, alt = "" }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired(
|
||||
{ tableName, recordId, fieldName, uploadId, imageUrl },
|
||||
['tableName', 'recordId', 'fieldName', 'uploadId', 'imageUrl'],
|
||||
'replace_record_image'
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
|
||||
// Step 1: Delete old upload
|
||||
await mcpPost(
|
||||
credentials,
|
||||
"deleteRecordUpload",
|
||||
{ uploadId },
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
);
|
||||
|
||||
// Step 2: Upload new image
|
||||
const response = await mcpPost(
|
||||
credentials,
|
||||
"uploadRecordImage",
|
||||
{ tableName, recordId, fieldName, imageUrl, alt },
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
);
|
||||
|
||||
const apiError = handleApiResponse(response.data, 'replace_record_image');
|
||||
if (apiError) return apiError;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: "Image replaced successfully",
|
||||
tableName,
|
||||
recordId,
|
||||
fieldName,
|
||||
replacedUploadId: uploadId,
|
||||
...response.data
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'replace_record_image', { tableName, recordId, fieldName, uploadId });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"delete_record_upload",
|
||||
"Delete an uploaded file from a record's upload field. Use list_record_uploads to get the uploadId first. Table names are WITHOUT the 'cms_' prefix.",
|
||||
withAuthParams({
|
||||
uploadId: z.string().describe("Upload ID to delete (get from list_record_uploads)"),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: true },
|
||||
withAuth(async ({ uploadId }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired(
|
||||
{ uploadId },
|
||||
['uploadId'],
|
||||
'delete_record_upload'
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
|
||||
const response = await mcpPost(
|
||||
credentials,
|
||||
"deleteRecordUpload",
|
||||
{ uploadId },
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
);
|
||||
|
||||
const apiError = handleApiResponse(response.data, 'delete_record_upload');
|
||||
if (apiError) return apiError;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: "Upload deleted successfully",
|
||||
uploadId,
|
||||
...response.data
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'delete_record_upload', { uploadId });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"reorder_record_uploads",
|
||||
"Reorder uploaded files in a record's upload field. Pass an array of upload IDs (num) in the desired order. Use list_record_uploads to get the current upload IDs first.",
|
||||
withAuthParams({
|
||||
uploadIds: z.array(z.union([z.string(), z.number()])).describe("Array of upload IDs (num field) in the desired display order"),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ uploadIds }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired(
|
||||
{ uploadIds },
|
||||
['uploadIds'],
|
||||
'reorder_record_uploads'
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
|
||||
const response = await mcpPost(
|
||||
credentials,
|
||||
"reorderRecordUploads",
|
||||
{ uploadIds },
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
);
|
||||
|
||||
const apiError = handleApiResponse(response.data, 'reorder_record_uploads');
|
||||
if (apiError) return apiError;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: "Uploads reordered successfully",
|
||||
...response.data
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'reorder_record_uploads', { uploadIds });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
211
mcp-server/tools/media/uploadImageToAssets.js
Normal file
211
mcp-server/tools/media/uploadImageToAssets.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import { z } from "zod";
|
||||
import axios from "axios";
|
||||
import sharp from "sharp";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleToolError } from "../helpers/errorHandler.js";
|
||||
import { saveFileBuilder } from "../helpers/fileBuilder.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
|
||||
/**
|
||||
* Upload an image to the website assets folder
|
||||
* Accepts base64, data URI, or URL
|
||||
* Optionally resizes/compresses the image
|
||||
*/
|
||||
export function registerUploadImageToAssetsTool(server) {
|
||||
server.tool(
|
||||
"upload_image_to_assets",
|
||||
"Upload an image to website assets (/images/). Accepts: base64, data URI, or URL. Optional resize (maxWidth/maxHeight) and compression (quality). Returns public URL.",
|
||||
withAuthParams({
|
||||
image: z.string().describe("Image data: base64 string, data URI, or URL to download from"),
|
||||
fileName: z.string().optional().describe("Custom filename (without extension). If not provided, auto-generated name will be used"),
|
||||
path: z.string().optional().default("/images/").describe("Path within assets folder (default: '/images/')"),
|
||||
|
||||
// Resize options
|
||||
maxWidth: z.number().optional().describe("Maximum width in pixels. Image will be resized proportionally if larger"),
|
||||
maxHeight: z.number().optional().describe("Maximum height in pixels. Image will be resized proportionally if larger"),
|
||||
quality: z.number().min(1).max(100).optional().default(85).describe("JPEG/WebP quality (1-100, default: 85)"),
|
||||
format: z.enum(["png", "jpg", "webp"]).optional().default("png").describe("Output format (default: png)"),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({
|
||||
image,
|
||||
fileName,
|
||||
path: assetsPath = "/images/",
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
quality = 85,
|
||||
format = "png"
|
||||
}, extra) => {
|
||||
try {
|
||||
let imageBuffer;
|
||||
|
||||
// Step 1: Get image buffer from various sources
|
||||
if (image.startsWith('data:')) {
|
||||
// Data URI format: data:image/png;base64,xxxxx
|
||||
const match = image.match(/data:image\/[^;]+;base64,(.+)/);
|
||||
if (match) {
|
||||
imageBuffer = Buffer.from(match[1], 'base64');
|
||||
} else {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: "Error: Invalid data URI format. Expected: data:image/xxx;base64,..."
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
} else if (image.startsWith('http://') || image.startsWith('https://')) {
|
||||
// URL - download the image
|
||||
try {
|
||||
const response = await axios.get(image, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000,
|
||||
maxContentLength: 50 * 1024 * 1024 // 50MB max
|
||||
});
|
||||
imageBuffer = Buffer.from(response.data, 'binary');
|
||||
} catch (downloadError) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Error downloading image from URL: ${downloadError.message}`
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Assume it's raw base64
|
||||
try {
|
||||
imageBuffer = Buffer.from(image, 'base64');
|
||||
} catch (base64Error) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: "Error: Could not parse image data. Provide base64, data URI, or URL"
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Validate we have a buffer
|
||||
if (!imageBuffer || imageBuffer.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: "Error: No valid image data received"
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Process image with sharp (resize/compress)
|
||||
let sharpInstance = sharp(imageBuffer);
|
||||
|
||||
// Get original metadata
|
||||
const metadata = await sharpInstance.metadata();
|
||||
const originalSize = imageBuffer.length;
|
||||
|
||||
// Resize if dimensions specified
|
||||
if (maxWidth || maxHeight) {
|
||||
sharpInstance = sharpInstance.resize({
|
||||
width: maxWidth,
|
||||
height: maxHeight,
|
||||
fit: 'inside', // Maintain aspect ratio
|
||||
withoutEnlargement: true // Don't upscale
|
||||
});
|
||||
}
|
||||
|
||||
// Convert to target format with quality
|
||||
let outputBuffer;
|
||||
let mimeType;
|
||||
let extension;
|
||||
|
||||
switch (format) {
|
||||
case 'jpg':
|
||||
outputBuffer = await sharpInstance.jpeg({ quality }).toBuffer();
|
||||
mimeType = 'image/jpeg';
|
||||
extension = 'jpg';
|
||||
break;
|
||||
case 'webp':
|
||||
outputBuffer = await sharpInstance.webp({ quality }).toBuffer();
|
||||
mimeType = 'image/webp';
|
||||
extension = 'webp';
|
||||
break;
|
||||
case 'png':
|
||||
default:
|
||||
outputBuffer = await sharpInstance.png({
|
||||
compressionLevel: Math.floor((100 - quality) / 11) // 0-9 compression
|
||||
}).toBuffer();
|
||||
mimeType = 'image/png';
|
||||
extension = 'png';
|
||||
break;
|
||||
}
|
||||
|
||||
// Step 3: Upload to assets
|
||||
const base64Image = outputBuffer.toString('base64');
|
||||
|
||||
// Generate filename if not provided
|
||||
const finalFileName = fileName
|
||||
? fileName.replace(/\.(jpg|jpeg|png|webp|gif)$/i, '') + '.' + extension
|
||||
: `uploaded-${Date.now()}.${extension}`;
|
||||
|
||||
// Get credentials
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
|
||||
// Upload using saveFileBuilder
|
||||
const uploadResult = await saveFileBuilder({
|
||||
web_url: credentials.web_url,
|
||||
token: credentials.token,
|
||||
tokenHash: credentials.tokenHash,
|
||||
path: assetsPath,
|
||||
fileName: finalFileName,
|
||||
content: base64Image,
|
||||
rawDataSended: false
|
||||
});
|
||||
|
||||
if (uploadResult && uploadResult.success) {
|
||||
// Build the public URL for the uploaded image
|
||||
const imageUrl = `${credentials.web_url}/template/estandar/images/${finalFileName}`;
|
||||
|
||||
// Get final metadata
|
||||
const finalMetadata = await sharp(outputBuffer).metadata();
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
imageUrl: imageUrl,
|
||||
fileName: finalFileName,
|
||||
path: assetsPath,
|
||||
format: format,
|
||||
originalSize: originalSize,
|
||||
finalSize: outputBuffer.length,
|
||||
compressionRatio: ((1 - outputBuffer.length / originalSize) * 100).toFixed(1) + '%',
|
||||
dimensions: {
|
||||
original: { width: metadata.width, height: metadata.height },
|
||||
final: { width: finalMetadata.width, height: finalMetadata.height }
|
||||
},
|
||||
message: `Image uploaded successfully. Use this URL: ${imageUrl}`
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: uploadResult?.message || "Unknown error uploading to assets"
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'upload_image_to_assets', { fileName, path: assetsPath });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
78
mcp-server/tools/modules/check.js
Normal file
78
mcp-server/tools/modules/check.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleToolError, handleApiResponse, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
|
||||
export function registerCheckModuleTool(server) {
|
||||
server.tool(
|
||||
"check_module",
|
||||
"Preview how a module renders with sample data. Returns a preview (first 50 lines + summary) by default — use fullRender=true for complete output. Always shows errors in full.",
|
||||
withAuthParams({
|
||||
moduleName: z.string().describe("Module ID/name to check"),
|
||||
vars: z.record(z.string(), z.any()).describe("Object with builder variable values. Keys should match the variable names from data-field-label (without spaces/special chars)"),
|
||||
fullRender: z.boolean().optional().describe("If true, returns complete rendered HTML. Default: false (preview — first 50 lines + summary, saves tokens)."),
|
||||
}),
|
||||
{ readOnlyHint: true, destructiveHint: false },
|
||||
withAuth(async ({ moduleName, vars, fullRender }, extra) => {
|
||||
const startTime = Date.now();
|
||||
console.error(`[Tool] check_module - START: moduleName=${moduleName}, varsCount=${Object.keys(vars || {}).length}, sessionId=${extra.sessionId}`);
|
||||
|
||||
try {
|
||||
// Validate required parameters
|
||||
const validationError = validateRequired(
|
||||
{ moduleName, vars },
|
||||
['moduleName', 'vars'],
|
||||
'check_module'
|
||||
);
|
||||
if (validationError) {
|
||||
console.error(`[Tool] check_module - VALIDATION ERROR: ${validationError.content[0].text}`);
|
||||
return validationError;
|
||||
}
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
|
||||
const payload = {
|
||||
moduleName: moduleName,
|
||||
vars: vars
|
||||
};
|
||||
|
||||
console.error(`[Tool] check_module - Calling AcaiHttpClient.checkModuleCode...`);
|
||||
const response = await AcaiHttpClient.checkModuleCode(credentials, credentials.token, payload);
|
||||
|
||||
// Check for API errors in response
|
||||
/*const apiError = handleApiResponse(response.data, 'check_module');
|
||||
if (apiError) {
|
||||
console.error(`[Tool] check_module - API ERROR: ${apiError.content[0].text}`);
|
||||
return apiError;
|
||||
}*/
|
||||
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
console.error(`[Tool] check_module - SUCCESS: completed in ${elapsedTime}ms`);
|
||||
|
||||
let outputText = `Module Preview for "${moduleName}":\n\n${JSON.stringify(response.data, null, 2)}`;
|
||||
|
||||
// Preview mode (default): truncate to first 50 lines + summary
|
||||
if (!fullRender) {
|
||||
const lines = outputText.split('\n');
|
||||
const PREVIEW_LINES = 50;
|
||||
if (lines.length > PREVIEW_LINES) {
|
||||
const preview = lines.slice(0, PREVIEW_LINES).join('\n');
|
||||
outputText = `${preview}\n\n--- PREVIEW: showing ${PREVIEW_LINES} of ${lines.length} lines. Use fullRender=true for complete output. ---`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: outputText
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
console.error(`[Tool] check_module - ERROR after ${elapsedTime}ms: ${error.message}`);
|
||||
return handleToolError(error, 'check_module', { moduleName, varsCount: Object.keys(vars || {}).length });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
62
mcp-server/tools/modules/checkUsage.js
Normal file
62
mcp-server/tools/modules/checkUsage.js
Normal file
@@ -0,0 +1,62 @@
|
||||
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 registerCheckModuleUsageTool(server) {
|
||||
server.tool(
|
||||
"check_module_usage",
|
||||
"Check which pages/URLs use a module. Call BEFORE delete_module to verify it's safe to remove.",
|
||||
withAuthParams({
|
||||
id: z.string().describe("Module ID to check usage for"),
|
||||
}),
|
||||
{ readOnlyHint: true, destructiveHint: false },
|
||||
withAuth(async ({ id }, extra) => {
|
||||
try {
|
||||
// Validate required parameters
|
||||
const validationError = validateRequired({ id }, ['id'], 'check_module_usage');
|
||||
if (validationError) return validationError;
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
|
||||
// Build the request payload
|
||||
const payload = {
|
||||
action_ws: "checkModuleInWeb",
|
||||
module: id,
|
||||
token: credentials.token,
|
||||
tokenHash: credentials.tokenHash
|
||||
};
|
||||
|
||||
// Make the request to the client's website
|
||||
const response = await AcaiHttpClient.postViewerFunctions(
|
||||
await getApiClient(extra.sessionId),
|
||||
payload
|
||||
);
|
||||
|
||||
// Check for API errors in response
|
||||
const apiError = handleApiResponse(response.data, 'check_module_usage');
|
||||
if (apiError) return apiError;
|
||||
|
||||
// Extract usage information
|
||||
const usageData = response.data.data || response.data;
|
||||
|
||||
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)`
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'check_module_usage', { id });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
82
mcp-server/tools/modules/compile.js
Normal file
82
mcp-server/tools/modules/compile.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import { z } from "zod";
|
||||
import axios from "axios";
|
||||
import path from "path";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
import { LOCAL_SERVER_URL } from "../../config/index.js";
|
||||
|
||||
export function registerCompileModuleTool(server) {
|
||||
server.tool(
|
||||
"compile_module",
|
||||
`Manually recompile a module or general section when generated files may be out of sync and you need to force compilation without editing index-base.tpl.
|
||||
Do not use this as part of the normal editing flow: most index-base.tpl edits made through the Acai file tools compile automatically.
|
||||
This is a recovery / resync tool, not a required step after routine changes.
|
||||
It parses the HTML into Twig, generates builder vars, and syncs with the Docker CMS.
|
||||
|
||||
Pass the full path to the index-base.tpl file and the project directory.`,
|
||||
withAuthParams({
|
||||
filePath: z.string().describe("Full absolute path to the index-base.tpl file that was edited"),
|
||||
projectDir: z.string().describe("Full absolute path to the project root directory"),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ filePath, projectDir }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired(
|
||||
{ filePath, projectDir },
|
||||
['filePath', 'projectDir'],
|
||||
'compile_module'
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const normalizedProjectDir = path.resolve(projectDir);
|
||||
const normalizedFilePath = path.resolve(filePath);
|
||||
const projectSlug = path.basename(normalizedProjectDir);
|
||||
const relativePath = path.relative(normalizedProjectDir, normalizedFilePath);
|
||||
const canUseSlugMode =
|
||||
!!projectSlug &&
|
||||
!!relativePath &&
|
||||
relativePath !== "" &&
|
||||
!relativePath.startsWith("..") &&
|
||||
!path.isAbsolute(relativePath);
|
||||
|
||||
const payload = canUseSlugMode
|
||||
? { project: projectSlug, relativePath, project_dir: projectDir }
|
||||
: { file: filePath, project_dir: projectDir };
|
||||
|
||||
// Call the Python server compile endpoint
|
||||
const response = await axios.post(
|
||||
`${LOCAL_SERVER_URL}/api/compile-module`,
|
||||
payload,
|
||||
{ headers: { "Content-Type": "application/json" }, timeout: 30000 }
|
||||
);
|
||||
|
||||
if (response.data?.ok) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: "Module compiled successfully",
|
||||
output: response.data.output || "",
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: response.data?.error || "Compilation failed",
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'compile_module', { filePath, projectDir });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
75
mcp-server/tools/modules/create.js
Normal file
75
mcp-server/tools/modules/create.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { z } from "zod";
|
||||
import axios from "axios";
|
||||
import { withAuth } from "../../auth/index.js";
|
||||
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
import { LOCAL_SERVER_URL } from "../../config/index.js";
|
||||
|
||||
export function registerCreateModuleTool(server) {
|
||||
server.tool(
|
||||
"create_module",
|
||||
`Create a new builder module in the project. This creates the module directory with index-base.tpl, style.css, and script.js, then compiles it automatically.
|
||||
|
||||
After creating the module, use add_module_to_record to place it on a page, then set_module_config_vars to fill its variables with content.
|
||||
|
||||
Parameters:
|
||||
- moduleId: unique identifier (lowercase, underscores, e.g. "hero_banner")
|
||||
- html: the Twig/HTML content for index-base.tpl
|
||||
- css: optional CSS for style.css
|
||||
- js: optional JavaScript for script.js
|
||||
- php: optional PHP code for module hook file .php
|
||||
- label: human-readable name (e.g. "Hero Banner V2")
|
||||
- description: brief description of what the module does`,
|
||||
withAuthParams({
|
||||
moduleId: z.string().describe("Module identifier (lowercase, underscores, e.g. 'hero_banner')"),
|
||||
html: z.string().describe("HTML/Twig content for index-base.tpl ( needed for compile module )"),
|
||||
css: z.string().optional().default("").describe("CSS content for style.css ( optional, you can also add CSS later on file )"),
|
||||
js: z.string().optional().default("").describe("JavaScript content for script.js ( optional, you can also add CSS later on file )"),
|
||||
php: z.string().optional().default("").describe("PHP code for module hook file .php ( optional, you can also add CSS later on file )"),
|
||||
label: z.string().optional().default("").describe("Human-readable module name"),
|
||||
description: z.string().optional().default("").describe("Brief description"),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ moduleId, html, css, js, php, label, description }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired({ moduleId, html }, ['moduleId', 'html'], 'create_module');
|
||||
if (validationError) return validationError;
|
||||
|
||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
||||
if (!projectDir) {
|
||||
return { content: [{ type: "text", text: "Error: ACAI_PROJECT_DIR not set" }], isError: true };
|
||||
}
|
||||
|
||||
moduleId = moduleId.toLowerCase().replace(/\s+/g, '_'); // Ensure moduleId is lowercase and uses underscores
|
||||
moduleId = moduleId + "_" + (Math.random().toString(36).substring(2, 8).toUpperCase());
|
||||
|
||||
const response = await axios.post(
|
||||
`${LOCAL_SERVER_URL}/api/create-module`,
|
||||
{ project_dir: projectDir, module_id: moduleId, html, css: css || "", js: js || "", label, description, php: php || "" },
|
||||
{ headers: { "Content-Type": "application/json" }, timeout: 30000 }
|
||||
);
|
||||
|
||||
if (response.data?.success) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
moduleId,
|
||||
path: response.data.path,
|
||||
compiled: response.data.compiled,
|
||||
note: response.data.compiled
|
||||
? "Module created and compiled. Use add_module_to_record to place it on a page, then set_module_config_vars to fill its variables."
|
||||
: "Module created but compilation failed: " + (response.data.compile_output || "unknown error"),
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} else {
|
||||
return { content: [{ type: "text", text: JSON.stringify(response.data) }], isError: true };
|
||||
}
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'create_module', { moduleId });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
9
mcp-server/tools/modules/index.js
Normal file
9
mcp-server/tools/modules/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { registerCheckModuleTool } from './check.js';
|
||||
import { registerCheckModuleUsageTool } from './checkUsage.js';
|
||||
import { registerCompileModuleTool } from './compile.js';
|
||||
|
||||
export function registerModuleTools(server) {
|
||||
registerCheckModuleTool(server);
|
||||
registerCheckModuleUsageTool(server);
|
||||
registerCompileModuleTool(server);
|
||||
}
|
||||
93
mcp-server/tools/modules/setExampleData.js
Normal file
93
mcp-server/tools/modules/setExampleData.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getSessionCredentials, getApiClient, getCommonParams } from "../../auth/index.js";
|
||||
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
|
||||
export function registerSetModuleExampleDataTool(server) {
|
||||
server.tool(
|
||||
"set_module_example_data",
|
||||
`Set example data for a module's editor preview. MANDATORY: call get_module first to get the schema, then fill EVERY variable.
|
||||
|
||||
Critical: uploads ALWAYS as [{urlPath: "..."}] (NEVER strings), multiv2 as array with 2+ items, var names from data-field-label (no spaces, lowercase). Use generate_image or placehold.co for image URLs.
|
||||
|
||||
See resource 'acai-cheat-sheet' → "Example Data Formatting" for type-specific value formats.`,
|
||||
withAuthParams({
|
||||
moduleId: z.string().describe("Module ID"),
|
||||
moduleSchema: z.object({}).passthrough().describe("Complete module schema (obtained from get_module)"),
|
||||
exampleData: z.object({}).passthrough().describe("Example data for EVERY variable in the module schema. Structure must match the schema exactly. Fill ALL variables without exception."),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ moduleId, moduleSchema, exampleData }, extra) => {
|
||||
try {
|
||||
// Validate required parameters
|
||||
const validationError = validateRequired({ moduleId, exampleData }, ['moduleId', 'exampleData'], 'set_module_example_data');
|
||||
if (validationError) return validationError;
|
||||
|
||||
// Validate that all schema variables are present in exampleData
|
||||
if (moduleSchema && moduleSchema.codeVars) {
|
||||
const schemaVars = Object.keys(moduleSchema.codeVars);
|
||||
const dataVars = Object.keys(exampleData);
|
||||
const missingVars = schemaVars.filter(v => !dataVars.includes(v));
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
console.warn(`[set_module_example_data] WARNING: Missing variables in exampleData: ${missingVars.join(', ')}`);
|
||||
}
|
||||
|
||||
// Check for upload fields that are not arrays
|
||||
for (const [varName, varInfo] of Object.entries(moduleSchema.codeVars)) {
|
||||
if (varInfo.type === 'upload' && exampleData[varName]) {
|
||||
if (!Array.isArray(exampleData[varName])) {
|
||||
console.error(`[set_module_example_data] ERROR: Upload field '${varName}' is not an array! Current value: ${JSON.stringify(exampleData[varName])}`);
|
||||
console.error(`[set_module_example_data] Upload fields MUST be arrays with urlPath objects: [{"urlPath": "..."}]`);
|
||||
} else if (exampleData[varName].length > 0 && !exampleData[varName][0].urlPath) {
|
||||
console.error(`[set_module_example_data] ERROR: Upload field '${varName}' items missing 'urlPath' property!`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
const client = await getApiClient(extra.sessionId);
|
||||
|
||||
// Log data for debugging
|
||||
console.error(`[set_module_example_data] Module ID: ${moduleId}`);
|
||||
console.error(`[set_module_example_data] Module Schema:`, JSON.stringify(moduleSchema, null, 2));
|
||||
console.error(`[set_module_example_data] Example Data:`, JSON.stringify(exampleData, null, 2));
|
||||
|
||||
// Prepare payload for setStaticVars action
|
||||
const payload = await getCommonParams(extra.sessionId, {
|
||||
action_ws: "setStaticVars",
|
||||
moduleId: moduleId,
|
||||
staticVars: exampleData,
|
||||
schema: moduleSchema
|
||||
});
|
||||
|
||||
console.error(`[set_module_example_data] Full Payload:`, JSON.stringify(payload, null, 2));
|
||||
|
||||
// Send to viewer_functions
|
||||
const response = await client.post("/cms/lib/viewer_functions.php", payload);
|
||||
|
||||
console.error(`[set_module_example_data] Response:`, JSON.stringify(response.data, null, 2));
|
||||
|
||||
// Check for API errors in response
|
||||
const apiError = handleApiResponse(response.data, 'set_module_example_data');
|
||||
if (apiError) return apiError;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text", text: JSON.stringify({
|
||||
success: true,
|
||||
message: `Example data set successfully for module '${moduleId}'`,
|
||||
moduleId: moduleId,
|
||||
dataCount: Object.keys(exampleData).length,
|
||||
schemaVarsCount: moduleSchema?.codeVars ? Object.keys(moduleSchema.codeVars).length : 0,
|
||||
response: response.data
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'set_module_example_data', { moduleId });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
5
mcp-server/tools/navigation/index.js
Normal file
5
mcp-server/tools/navigation/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { registerNavigateBrowserTool } from './navigate.js';
|
||||
|
||||
export function registerNavigationTools(server) {
|
||||
registerNavigateBrowserTool(server);
|
||||
}
|
||||
57
mcp-server/tools/navigation/navigate.js
Normal file
57
mcp-server/tools/navigation/navigate.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleToolError } from "../helpers/errorHandler.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
import { LOCAL_SERVER_URL } from "../../config/index.js";
|
||||
import axios from "axios";
|
||||
|
||||
export function registerNavigateBrowserTool(server) {
|
||||
server.tool(
|
||||
"navigate_browser",
|
||||
`Navigate the user's browser preview to a specific page URL. Use this after creating or modifying a page to show the result to the user. The enlace should be a path like "/servicios/" or "/blog/my-post/".`,
|
||||
withAuthParams({
|
||||
enlace: z.string().describe("The URL path to navigate to, e.g. '/servicios/' or '/contacto/'"),
|
||||
}),
|
||||
{ readOnlyHint: true, destructiveHint: false },
|
||||
withAuth(async ({ enlace }, extra) => {
|
||||
try {
|
||||
if (!enlace) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: enlace is required" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure enlace starts with /
|
||||
if (!enlace.startsWith("/")) {
|
||||
enlace = "/" + enlace;
|
||||
}
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
const project = credentials.website || process.env.ACAI_WEBSITE || "";
|
||||
|
||||
// POST to Python server to set pending navigation
|
||||
await axios.post(`${LOCAL_SERVER_URL}/api/browser/navigate`, {
|
||||
project: project,
|
||||
enlace: enlace,
|
||||
}, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: `Browser navigated to ${enlace}`,
|
||||
enlace: enlace,
|
||||
})
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, "navigate_browser", { enlace });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
345
mcp-server/tools/orchestrator/detector.js
Normal file
345
mcp-server/tools/orchestrator/detector.js
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Workflow auto-detection engine.
|
||||
* Keyword-based pattern matching with weighted scoring + contextual adjustments.
|
||||
* No LLM call needed — fast and deterministic.
|
||||
*/
|
||||
|
||||
const WORKFLOW_PATTERNS = {
|
||||
create_section: {
|
||||
keywords: [
|
||||
"crear seccion", "create section", "nueva seccion", "new section",
|
||||
"anadir seccion", "add section", "crear tabla", "create table",
|
||||
"nueva pagina", "new page", "nueva seccion web", "new web section",
|
||||
"montar seccion", "set up section", "configurar seccion",
|
||||
// Additional English patterns
|
||||
"build section", "build page", "make section", "make page",
|
||||
"set up page", "create page", "new table",
|
||||
"section for", "seccion de", "seccion para",
|
||||
// Natural phrasing
|
||||
"want section", "need section", "quiero seccion",
|
||||
"necesito seccion", "hacer seccion", "hacer pagina"
|
||||
],
|
||||
boost: [
|
||||
"categoria", "category", "productos", "products", "blog", "noticias",
|
||||
"news", "equipo", "team", "servicios", "services", "galeria", "gallery",
|
||||
"portfolio", "testimonios", "testimonials", "faq", "preguntas",
|
||||
"clientes", "clients", "proyectos", "projects",
|
||||
"restaurante", "restaurant", "tienda", "store", "shop",
|
||||
"eventos", "events", "cursos", "courses"
|
||||
],
|
||||
weight: 10
|
||||
},
|
||||
populate_content: {
|
||||
keywords: [
|
||||
"anadir contenido", "add content", "crear registros", "create records",
|
||||
"poblar", "populate", "rellenar", "fill", "bulk", "masivo",
|
||||
"insertar datos", "insert data", "meter datos", "cargar contenido",
|
||||
"load content", "contenido de ejemplo", "sample content",
|
||||
"crear entradas", "create entries", "anadir registros", "add records",
|
||||
"registros de ejemplo", "sample records", "meter registros",
|
||||
"fill with data", "fill with content", "add sample", "add examples",
|
||||
"anadir ejemplos", "contenido de prueba", "test content"
|
||||
],
|
||||
boost: [
|
||||
"imagenes", "images", "fotos", "photos", "stock", "ejemplo", "sample",
|
||||
"demo", "placeholder", "varios", "multiple", "lote", "batch"
|
||||
],
|
||||
weight: 10
|
||||
},
|
||||
create_module: {
|
||||
keywords: [
|
||||
"crear modulo", "create module", "nuevo modulo", "new module",
|
||||
"disenar modulo", "design module", "hacer modulo", "make module",
|
||||
"componente", "component", "crear componente", "create component",
|
||||
"nuevo componente", "new component", "montar modulo",
|
||||
"build module", "build component", "make component"
|
||||
],
|
||||
boost: [
|
||||
"hero", "slider", "card", "grid", "lista", "list", "banner",
|
||||
"footer", "header", "navbar", "cta", "call to action",
|
||||
"carousel", "accordion", "tabs", "pricing", "features"
|
||||
],
|
||||
weight: 10
|
||||
},
|
||||
edit_module: {
|
||||
keywords: [
|
||||
"editar modulo", "edit module", "modificar modulo", "modify module",
|
||||
"cambiar modulo", "change module", "actualizar modulo", "update module",
|
||||
"arreglar modulo", "fix module", "mejorar modulo", "improve module",
|
||||
"corregir modulo", "ajustar modulo", "adjust module"
|
||||
],
|
||||
boost: [
|
||||
"css", "html", "javascript", "js", "estilo", "style", "variable",
|
||||
"campo", "field", "diseno", "design", "responsive", "movil", "mobile",
|
||||
"color", "fuente", "font", "espaciado", "spacing",
|
||||
"hero", "slider", "card", "grid", "banner", "footer", "header",
|
||||
"navbar", "cta", "carousel", "accordion", "tabs", "pricing"
|
||||
],
|
||||
weight: 10
|
||||
},
|
||||
manage_records: {
|
||||
keywords: [
|
||||
"editar registro", "edit record", "actualizar registro", "update record",
|
||||
"borrar registro", "delete record", "buscar registro", "search record",
|
||||
"listar registros", "list records", "modificar registro", "modify record",
|
||||
"ver registros", "view records", "consultar registros", "query records",
|
||||
"cambiar datos", "change data", "eliminar registro", "remove record",
|
||||
// CRUD-oriented English patterns
|
||||
"update data", "delete data", "edit data", "modify data",
|
||||
"update field", "change field", "edit entry", "delete entry",
|
||||
"update price", "change price", "update name", "change name",
|
||||
"remove records", "remove entries", "crud",
|
||||
"insert record", "insert entry", "create record", "add entry",
|
||||
"find record", "find records", "search records", "search data"
|
||||
],
|
||||
boost: [
|
||||
"filtrar", "filter", "where", "campo", "field", "valor", "value",
|
||||
"pagina", "page", "ordenar", "sort", "buscar", "search",
|
||||
"precio", "price", "nombre", "name", "fecha", "date",
|
||||
"estado", "status", "activo", "active"
|
||||
],
|
||||
weight: 8
|
||||
},
|
||||
manage_media: {
|
||||
// Only specific action phrases — generic words like "image/foto" are in boost, not keywords
|
||||
keywords: [
|
||||
"subir imagen", "upload image", "subir foto", "upload photo",
|
||||
"buscar imagen stock", "search stock image", "buscar fotos stock",
|
||||
"generar imagen", "generate image", "generar foto",
|
||||
"reemplazar imagen", "replace image", "cambiar imagen", "change image",
|
||||
"borrar imagen", "delete image", "eliminar imagen", "remove image",
|
||||
"gestionar media", "manage media", "gestionar imagenes", "manage images",
|
||||
"buscar stock", "search stock", "stock photos", "fotos stock",
|
||||
"subir archivo", "upload file"
|
||||
],
|
||||
boost: [
|
||||
"stock", "pixabay", "pexels", "ai", "inteligencia artificial",
|
||||
"resize", "thumbnail", "miniatura", "s3", "assets",
|
||||
"comprimir", "compress", "optimizar", "optimize",
|
||||
// Generic image words are boosts, NOT keywords
|
||||
"imagen", "image", "foto", "photo", "galeria", "gallery", "media"
|
||||
],
|
||||
weight: 5 // Reduced from 8 — media is usually a step, not a workflow
|
||||
},
|
||||
seo_setup: {
|
||||
keywords: [
|
||||
"seo", "meta tags", "meta descripcion", "meta description",
|
||||
"enlace", "slug", "url amigable", "friendly url", "sitemap",
|
||||
"schema markup", "posicionamiento", "ranking",
|
||||
"meta titulo", "meta title", "configurar seo", "setup seo",
|
||||
"set up seo", "configure seo"
|
||||
],
|
||||
boost: [
|
||||
"google", "keywords", "palabras clave", "busqueda", "search",
|
||||
"indexar", "index", "robots", "canonical", "og:image"
|
||||
],
|
||||
weight: 6
|
||||
},
|
||||
explore_site: {
|
||||
keywords: [
|
||||
"explorar", "explore", "que tiene", "what's in", "listar todo",
|
||||
"list all", "mostrar", "show me", "overview", "resumen",
|
||||
"que hay", "que secciones", "what sections", "ver todo",
|
||||
"show everything", "estructura", "structure", "inventario",
|
||||
"mapa del sitio", "site map", "what modules", "que modulos"
|
||||
],
|
||||
boost: [
|
||||
"estructura", "structure", "mapa", "map", "resumen", "summary",
|
||||
"completo", "complete", "todas", "all"
|
||||
],
|
||||
weight: 5
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize text for matching: lowercase, remove accents, strip common articles, trim.
|
||||
*/
|
||||
function normalizeText(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare task text for matching: normalize + strip common filler words (articles, prepositions)
|
||||
* that break keyword matching (e.g., "editar el módulo" should match "editar módulo").
|
||||
*/
|
||||
function prepareTaskForMatching(text) {
|
||||
const normalized = normalizeText(text);
|
||||
// Strip common Spanish/English articles and short prepositions that break adjacent keyword matching
|
||||
return normalized.replace(/\b(el|la|los|las|un|una|unos|unas|del|al|the|a|an)\b/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
// ── Contextual adjustment patterns ──────────────────────────────────────────
|
||||
// These use regex word matching to detect intent combinations that substring
|
||||
// matching misses (e.g., "create a new products section" has words separated).
|
||||
|
||||
const CREATION_VERBS = /\b(crear|create|nueva?o?|new|build|make|set up|montar|anadir|add|disenar|design|hacer)\b/;
|
||||
const EDIT_VERBS = /\b(editar|edit|modificar|modify|cambiar|change|actualizar|update|arreglar|fix|mejorar|improve|ajustar|adjust|corregir)\b/;
|
||||
const CRUD_VERBS = /\b(editar|edit|borrar|delete|eliminar|remove|actualizar|update|crear|create|insertar|insert|modificar|modify|buscar|search|listar|list|consultar|query|cambiar|change|find|get|ver|view)\b/;
|
||||
const SECTION_WORDS = /\b(seccion|section|pagina|page|tabla|table|web|sitio|site)\b/;
|
||||
const MODULE_WORDS = /\b(modulo|module|componente|component)\b/;
|
||||
const RECORD_WORDS = /\b(registro|registros|record|records|datos|data|entrada|entradas|entry|entries|contenido|content|precio|price|campo|field)\b/;
|
||||
const MEDIA_ONLY_WORDS = /\b(subir|upload|reemplazar|replace|descargar|download)\b/;
|
||||
const IMAGE_WORDS = /\b(imagen|imagenes|image|images|foto|fotos|photo|photos|galeria|gallery)\b/;
|
||||
// Words that indicate the task is about content/records, not creating a new section
|
||||
const CONTENT_INTENT_WORDS = /\b(contenido|content|rellenar|fill|poblar|populate|registros|records|sample|ejemplo|articulos|articles|entradas|entries|anadir contenido|add content)\b/;
|
||||
// Words that indicate the task is about SEO, not creating a new section
|
||||
const SEO_INTENT_WORDS = /\b(seo|meta tags?|meta descripcion|meta description|meta titulo|meta title|sitemap|slug|posicionamiento|ranking|canonical)\b/;
|
||||
|
||||
/**
|
||||
* Post-scoring contextual adjustments.
|
||||
* Uses regex word matching (not substring) to detect intent patterns the keyword
|
||||
* phase may miss due to non-adjacent words.
|
||||
*/
|
||||
function applyContextAdjustments(scores, normalizedTask) {
|
||||
const hasCreationVerb = CREATION_VERBS.test(normalizedTask);
|
||||
const hasEditVerb = EDIT_VERBS.test(normalizedTask);
|
||||
const hasCrudVerb = CRUD_VERBS.test(normalizedTask);
|
||||
const hasSection = SECTION_WORDS.test(normalizedTask);
|
||||
const hasModule = MODULE_WORDS.test(normalizedTask);
|
||||
const hasRecord = RECORD_WORDS.test(normalizedTask);
|
||||
const hasMediaAction = MEDIA_ONLY_WORDS.test(normalizedTask);
|
||||
const hasImageWord = IMAGE_WORDS.test(normalizedTask);
|
||||
const hasContentIntent = CONTENT_INTENT_WORDS.test(normalizedTask);
|
||||
const hasSeoIntent = SEO_INTENT_WORDS.test(normalizedTask);
|
||||
|
||||
// ── Section creation intent ──
|
||||
// "create" + "section/page/table" = strong signal for create_section
|
||||
// BUT NOT when the real intent is populating content or configuring SEO
|
||||
if (hasCreationVerb && hasSection && !hasContentIntent && !hasSeoIntent) {
|
||||
scores.create_section = scores.create_section || { score: 0, keywordHits: 0, boostHits: 0 };
|
||||
scores.create_section.score += 20;
|
||||
}
|
||||
|
||||
// ── Module creation intent ──
|
||||
// "create/new" + "module/component" = strong signal for create_module
|
||||
if (hasCreationVerb && hasModule) {
|
||||
scores.create_module = scores.create_module || { score: 0, keywordHits: 0, boostHits: 0 };
|
||||
scores.create_module.score += 20;
|
||||
}
|
||||
|
||||
// ── Module edit intent ──
|
||||
// "edit/modify/change" + "module/component" = strong signal for edit_module
|
||||
if (hasEditVerb && hasModule) {
|
||||
scores.edit_module = scores.edit_module || { score: 0, keywordHits: 0, boostHits: 0 };
|
||||
scores.edit_module.score += 20;
|
||||
}
|
||||
|
||||
// ── Decisive create vs edit for modules ──
|
||||
// When both create_module and edit_module have scores, apply decisive differentiation
|
||||
if (hasModule && scores.create_module && scores.edit_module) {
|
||||
if (hasCreationVerb && !hasEditVerb) {
|
||||
// Clearly creation intent → penalize edit
|
||||
scores.edit_module.score = Math.max(0, scores.edit_module.score - 15);
|
||||
} else if (hasEditVerb && !hasCreationVerb) {
|
||||
// Clearly edit intent → penalize create
|
||||
scores.create_module.score = Math.max(0, scores.create_module.score - 15);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Record CRUD intent ──
|
||||
// Any CRUD verb + "record/data/entry" = signal for manage_records
|
||||
if (hasCrudVerb && hasRecord) {
|
||||
scores.manage_records = scores.manage_records || { score: 0, keywordHits: 0, boostHits: 0 };
|
||||
scores.manage_records.score += 15;
|
||||
}
|
||||
|
||||
// ── Penalize manage_media when context is clearly about something else ──
|
||||
// If the task mentions section/module/record context, media is a step not the workflow
|
||||
if (scores.manage_media && (hasSection || hasModule || hasRecord)) {
|
||||
// Only keep media score if there's an explicit media action verb ("upload", "replace")
|
||||
if (!hasMediaAction) {
|
||||
scores.manage_media.score = Math.max(0, Math.floor(scores.manage_media.score * 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Boost manage_media only when it's the clear primary intent ──
|
||||
// "upload/replace" + "image/photo" WITHOUT section/module/record context
|
||||
if (hasMediaAction && hasImageWord && !hasSection && !hasModule && !hasRecord) {
|
||||
scores.manage_media = scores.manage_media || { score: 0, keywordHits: 0, boostHits: 0 };
|
||||
scores.manage_media.score += 10;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the best workflow for a given task description.
|
||||
* Returns the top match with confidence, or suggestions if ambiguous.
|
||||
*
|
||||
* @param {string} task - The user's task description
|
||||
* @returns {{ workflow: string, confidence: number, alternatives: Array }}
|
||||
*/
|
||||
export function detectWorkflow(task) {
|
||||
const normalizedTask = prepareTaskForMatching(task);
|
||||
const scores = {};
|
||||
|
||||
// ── Phase 1: Keyword + boost scoring ──
|
||||
for (const [workflowId, pattern] of Object.entries(WORKFLOW_PATTERNS)) {
|
||||
let score = 0;
|
||||
let keywordHits = 0;
|
||||
let boostHits = 0;
|
||||
|
||||
// Check keyword matches
|
||||
for (const keyword of pattern.keywords) {
|
||||
if (normalizedTask.includes(normalizeText(keyword))) {
|
||||
keywordHits++;
|
||||
}
|
||||
}
|
||||
|
||||
// Check boost matches
|
||||
for (const boost of pattern.boost) {
|
||||
if (normalizedTask.includes(normalizeText(boost))) {
|
||||
boostHits++;
|
||||
}
|
||||
}
|
||||
|
||||
score = (keywordHits * pattern.weight) + (boostHits * 3);
|
||||
scores[workflowId] = { score, keywordHits, boostHits };
|
||||
}
|
||||
|
||||
// ── Phase 2: Contextual adjustments ──
|
||||
// Uses regex word matching to catch intent patterns that substring matching misses
|
||||
applyContextAdjustments(scores, normalizedTask);
|
||||
|
||||
// Sort by score descending
|
||||
const ranked = Object.entries(scores)
|
||||
.filter(([, data]) => data.score > 0)
|
||||
.sort(([, a], [, b]) => b.score - a.score);
|
||||
|
||||
if (ranked.length === 0) {
|
||||
return {
|
||||
workflow: null,
|
||||
confidence: 0,
|
||||
alternatives: []
|
||||
};
|
||||
}
|
||||
|
||||
const [topId, topData] = ranked[0];
|
||||
const maxPossibleScore = WORKFLOW_PATTERNS[topId].keywords.length * WORKFLOW_PATTERNS[topId].weight
|
||||
+ WORKFLOW_PATTERNS[topId].boost.length * 3;
|
||||
const confidence = Math.min(topData.score / Math.max(maxPossibleScore * 0.15, 1), 1);
|
||||
|
||||
// Check if top 2 are close (ambiguous)
|
||||
const alternatives = ranked.slice(1, 3).map(([id, data]) => ({
|
||||
workflow: id,
|
||||
score: data.score,
|
||||
confidence: Math.min(data.score / Math.max(
|
||||
WORKFLOW_PATTERNS[id].keywords.length * WORKFLOW_PATTERNS[id].weight * 0.15, 1
|
||||
), 1)
|
||||
}));
|
||||
|
||||
const isAmbiguous = alternatives.length > 0
|
||||
&& alternatives[0].score > 0
|
||||
&& (topData.score - alternatives[0].score) < (topData.score * 0.2);
|
||||
|
||||
return {
|
||||
workflow: topId,
|
||||
confidence: Math.round(confidence * 100) / 100,
|
||||
ambiguous: isAmbiguous,
|
||||
alternatives
|
||||
};
|
||||
}
|
||||
|
||||
export { WORKFLOW_PATTERNS };
|
||||
5
mcp-server/tools/orchestrator/index.js
Normal file
5
mcp-server/tools/orchestrator/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { registerOrchestrateTool } from "./orchestrate.js";
|
||||
|
||||
export function registerOrchestratorTools(server) {
|
||||
registerOrchestrateTool(server);
|
||||
}
|
||||
165
mcp-server/tools/orchestrator/orchestrate.js
Normal file
165
mcp-server/tools/orchestrator/orchestrate.js
Normal file
@@ -0,0 +1,165 @@
|
||||
import { z } from "zod";
|
||||
import { detectWorkflow } from "./detector.js";
|
||||
import { getWorkflow, listWorkflows } from "./workflows/index.js";
|
||||
|
||||
/**
|
||||
* Register the orchestrate_task tool on the MCP server.
|
||||
*/
|
||||
export function registerOrchestrateTool(server) {
|
||||
server.tool(
|
||||
"orchestrate_task",
|
||||
"Provides workflow context, domain rules, and step-by-step guidance for Acai CMS tasks. " +
|
||||
"Returns relevant warnings, resource pointers, and suggested tool order. " +
|
||||
"Optional but recommended for multi-step tasks — helps avoid common mistakes. " +
|
||||
"Available workflows: create_section, populate_content, create_module, edit_module, " +
|
||||
"manage_records, manage_media, seo_setup, explore_site.",
|
||||
{
|
||||
task: z.string().describe(
|
||||
"The user's task or request in their own words. " +
|
||||
"Example: 'Crear una sección de productos con categorías e imágenes'"
|
||||
),
|
||||
forceWorkflow: z.string().optional().describe(
|
||||
"Optional: force a specific workflow instead of auto-detecting. " +
|
||||
"Use when auto-detection is wrong or you know exactly which workflow to use. " +
|
||||
"Values: create_section, populate_content, create_module, edit_module, " +
|
||||
"manage_records, manage_media, seo_setup, explore_site"
|
||||
)
|
||||
},
|
||||
{ readOnlyHint: true, destructiveHint: false },
|
||||
async ({ task, forceWorkflow }) => {
|
||||
try {
|
||||
let workflowId;
|
||||
let confidence;
|
||||
let detectionInfo;
|
||||
|
||||
if (forceWorkflow) {
|
||||
// Forced workflow — skip detection
|
||||
workflowId = forceWorkflow;
|
||||
confidence = 1.0;
|
||||
detectionInfo = { method: "forced", forceWorkflow };
|
||||
} else {
|
||||
// Auto-detect workflow from task description
|
||||
const detection = detectWorkflow(task);
|
||||
|
||||
if (!detection.workflow) {
|
||||
// No workflow matched — return general orientation
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
workflow: "none_detected",
|
||||
message: "Could not determine a specific workflow for this task. " +
|
||||
"You can proceed freely using available tools, or specify a workflow with forceWorkflow.",
|
||||
availableWorkflows: listWorkflows(),
|
||||
generalRules: [
|
||||
"Table names WITHOUT 'cms_' prefix in all tool calls",
|
||||
"Primary key is ALWAYS 'num', never 'id'",
|
||||
"Upload fields are ALWAYS arrays of objects with urlPath property",
|
||||
"Use ONLY Twig FILTERS (pipe syntax), not Twig functions",
|
||||
"Date format: YYYY-MM-DD HH:mm:ss",
|
||||
"Checkbox values: 1 or 0 (number, not boolean)"
|
||||
]
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
if (detection.ambiguous) {
|
||||
// Ambiguous — return top suggestions
|
||||
const topWorkflow = getWorkflow(detection.workflow);
|
||||
const altWorkflows = detection.alternatives
|
||||
.map(a => getWorkflow(a.workflow))
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
workflow: "ambiguous",
|
||||
message: "Multiple workflows could match this task. " +
|
||||
"Pick the most appropriate one using forceWorkflow, or proceed with the top match.",
|
||||
topMatch: {
|
||||
id: topWorkflow.id,
|
||||
name: topWorkflow.name,
|
||||
description: topWorkflow.description,
|
||||
confidence: detection.confidence
|
||||
},
|
||||
alternatives: altWorkflows.map((w, i) => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
description: w.description,
|
||||
confidence: detection.alternatives[i].confidence
|
||||
}))
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
workflowId = detection.workflow;
|
||||
confidence = detection.confidence;
|
||||
detectionInfo = {
|
||||
method: "auto",
|
||||
confidence: detection.confidence,
|
||||
alternatives: detection.alternatives
|
||||
};
|
||||
}
|
||||
|
||||
// Load the workflow
|
||||
const workflow = getWorkflow(workflowId);
|
||||
if (!workflow) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `Unknown workflow: '${workflowId}'`,
|
||||
availableWorkflows: listWorkflows()
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Build the response
|
||||
const response = {
|
||||
success: true,
|
||||
workflow: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
confidence,
|
||||
detection: detectionInfo,
|
||||
totalSteps: workflow.steps.length,
|
||||
steps: workflow.steps,
|
||||
context: workflow.context,
|
||||
rules: workflow.rules,
|
||||
warnings: workflow.warnings,
|
||||
resources: workflow.resources
|
||||
};
|
||||
|
||||
console.error(`[Orchestrator] Detected workflow: ${workflow.id} (confidence: ${confidence}) for task: "${task.substring(0, 80)}..."`);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}]
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error("[Orchestrator] Error:", error);
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
85
mcp-server/tools/orchestrator/workflows/createModule.js
Normal file
85
mcp-server/tools/orchestrator/workflows/createModule.js
Normal file
@@ -0,0 +1,85 @@
|
||||
export const createModuleWorkflow = {
|
||||
id: "create_module",
|
||||
name: "Create Module",
|
||||
description: "Design and create an HTML module by writing project files directly, then compile it in the CMS.",
|
||||
steps: [
|
||||
{
|
||||
step: 1,
|
||||
action: "Understand the design",
|
||||
description: "Clarify with user: what does the module show? Is it a hero, grid, list, slider, CTA, form?",
|
||||
tool: null,
|
||||
critical: "Get clear requirements before writing code. Ask about: layout, colors, responsive behavior, editable fields."
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
action: "Review project styling and patterns",
|
||||
description: "Use the saved project styles and nearby modules as reference before writing code.",
|
||||
tool: "save_project_styles",
|
||||
critical: "Align typography, spacing, colors, and component patterns with the existing project."
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
action: "Create the module files",
|
||||
description: "Write index-base.tpl, style.css, script.js, and optional hook.php directly in the module folder.",
|
||||
tool: "acai-write",
|
||||
critical: "Use project-relative paths. Create complete files. Keep variable names lowercase, descriptive, and stable."
|
||||
},
|
||||
{
|
||||
step: 4,
|
||||
action: "Refine targeted blocks if needed",
|
||||
description: "Use incremental replacements for small fixes instead of rewriting whole files.",
|
||||
tool: "acai-line-replace",
|
||||
critical: "Prefer block edits for existing files to reduce token usage and avoid accidental rewrites."
|
||||
},
|
||||
{
|
||||
step: 5,
|
||||
action: "Compile the module",
|
||||
description: "Compile after editing index-base.tpl so the CMS syncs index.tpl and builder metadata.",
|
||||
tool: "compile_module",
|
||||
critical: "This is mandatory after every index-base.tpl change."
|
||||
},
|
||||
{
|
||||
step: 6,
|
||||
action: "Set example data",
|
||||
description: "Set example/static data for module preview. MUST call get_module first to discover the variable schema.",
|
||||
tool: "set_module_example_data",
|
||||
critical: "Call get_module first to get ALL variable names. Then fill EVERY variable with realistic example data. Missing variables = blank preview."
|
||||
},
|
||||
{
|
||||
step: 7,
|
||||
action: "Check module rendering",
|
||||
description: "Test the module with specific variable values to verify it renders correctly.",
|
||||
tool: "check_module",
|
||||
critical: "Test with realistic values. Check for Twig syntax errors, broken images, layout issues."
|
||||
}
|
||||
],
|
||||
context: {
|
||||
builder_vars: "data-field-type attribute on elements creates editable fields. Types: textfield (single line text), headfield (heading), textbox (multiline), wysiwyg (rich HTML), link (URL), upload (single image), uploadBackground (background image), uploadMulti (gallery), list (dropdown options), multiv2 (repeatable block).",
|
||||
component_syntax: "c-if='varname' shows/hides element based on variable. c-for='item in items' loops over array. c-hidden='true' makes element invisible (for config vars). c-else after c-if for alternative content.",
|
||||
module_structure: "Create index-base.tpl, style.css, script.js, and optional hook.php in the module directory. Compile to generate builder.json and the public templates.",
|
||||
css_conventions: "Use TailwindCSS by default. For custom CSS: BEM naming with kebab-case. Root class should match module name. Avoid !important.",
|
||||
upload_in_modules: "Upload fields are arrays. Single image: {{ varname[0].urlPath | imagec(WIDTH) }}. Background: style=\"background-image: url('{{ varname[0].urlPath | imagec(1920) }}')\". Gallery: {% for img in varname %}{{ img.urlPath }}{% endfor %}."
|
||||
},
|
||||
rules: [
|
||||
"Variable names: lowercase, no spaces, no accents, no special characters",
|
||||
"Labels must be UNIQUE — duplicate labels create shared fields",
|
||||
"Upload fields are ALWAYS arrays — access with [0].urlPath",
|
||||
"Use ONLY Twig FILTERS (pipe syntax), not Twig functions",
|
||||
"c-if='varname' for conditional rendering of optional fields",
|
||||
"c-hidden='true' for configuration variables not shown to end user",
|
||||
"data-field-width on upload elements to set image optimization width",
|
||||
"For multiv2 (repeatable): parent element needs data-field-type='multiv2', children are the repeated fields"
|
||||
],
|
||||
warnings: [
|
||||
"DO NOT use duplicate labels — they create shared/linked fields",
|
||||
"DO NOT forget to set example data — the module will appear blank in the editor",
|
||||
"DO NOT use Twig functions (range, random, etc.) — only filters work",
|
||||
"DO NOT access upload vars as strings — always use varname[0].urlPath (array)",
|
||||
"DO NOT mix React/Vue syntax — use Twig for templating, vanilla JS for interactivity"
|
||||
],
|
||||
resources: [
|
||||
"acai://resources/guia-builder-vars",
|
||||
"acai://resources/guia-atributos-acai",
|
||||
"acai://resources/guia-programacion-acai"
|
||||
]
|
||||
};
|
||||
110
mcp-server/tools/orchestrator/workflows/createSection.js
Normal file
110
mcp-server/tools/orchestrator/workflows/createSection.js
Normal file
@@ -0,0 +1,110 @@
|
||||
export const createSectionWorkflow = {
|
||||
id: "create_section",
|
||||
name: "Create New Section",
|
||||
description: "Full workflow for creating a new website section: table + fields + module + template + content.",
|
||||
steps: [
|
||||
{
|
||||
step: 1,
|
||||
action: "Understand requirements",
|
||||
description: "Clarify with user: section name, type (multi/single/category), fields needed, whether it needs URL (enlace), SEO meta tags.",
|
||||
tool: null,
|
||||
critical: "Ask before acting. Multi = multiple records (blog, products). Single = one record (about page). Category = grouping for other sections."
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
action: "Check existing tables",
|
||||
description: "List current tables to avoid naming conflicts and understand existing structure.",
|
||||
tool: "list_tables",
|
||||
critical: "Table names must be unique. Check if a similar section already exists."
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
action: "Create the table",
|
||||
description: "Create the database table with correct type and configuration.",
|
||||
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."
|
||||
},
|
||||
{
|
||||
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."
|
||||
},
|
||||
{
|
||||
step: 5,
|
||||
action: "Verify table schema",
|
||||
description: "Get the complete schema to confirm all fields were created correctly.",
|
||||
tool: "get_table_schema",
|
||||
critical: "Verify all fields exist with correct types before proceeding to module creation."
|
||||
},
|
||||
{
|
||||
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)."
|
||||
},
|
||||
{
|
||||
step: 7,
|
||||
action: "Set module example data",
|
||||
description: "Set example/static data for module preview. MUST call get_module first to discover ALL variables.",
|
||||
tool: "set_module_example_data",
|
||||
critical: "Every builder variable must have example data. Missing variables cause blank previews."
|
||||
},
|
||||
{
|
||||
step: 8,
|
||||
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."
|
||||
},
|
||||
{
|
||||
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."
|
||||
},
|
||||
{
|
||||
step: 10,
|
||||
action: "Verify the result",
|
||||
description: "Check module rendering with actual variable values.",
|
||||
tool: "check_module",
|
||||
critical: "Test with actual variable values to ensure no rendering errors."
|
||||
}
|
||||
],
|
||||
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'.",
|
||||
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 %}."
|
||||
},
|
||||
rules: [
|
||||
"Table names WITHOUT 'cms_' prefix in all tool calls",
|
||||
"Primary key is ALWAYS 'num', never 'id'",
|
||||
"Upload fields are ALWAYS arrays of objects with urlPath property",
|
||||
"Use ONLY Twig FILTERS (pipe syntax), not Twig functions",
|
||||
"Date format: YYYY-MM-DD HH:mm:ss",
|
||||
"Checkbox values: 1 or 0 (number, not boolean)",
|
||||
"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'"
|
||||
],
|
||||
warnings: [
|
||||
"DO NOT use record.imagen.urlPath — it's record.imagen[0].urlPath (array!)",
|
||||
"DO NOT use 'id' as primary key — Acai uses 'num'",
|
||||
"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"
|
||||
],
|
||||
resources: [
|
||||
"acai://resources/guia-builder-vars",
|
||||
"acai://resources/guia-twig-filters",
|
||||
"acai://resources/guia-atributos-acai",
|
||||
"acai://resources/guia-registros"
|
||||
]
|
||||
};
|
||||
64
mcp-server/tools/orchestrator/workflows/editModule.js
Normal file
64
mcp-server/tools/orchestrator/workflows/editModule.js
Normal file
@@ -0,0 +1,64 @@
|
||||
export const editModuleWorkflow = {
|
||||
id: "edit_module",
|
||||
name: "Edit Module",
|
||||
description: "Modify an existing HTML module: update code, styles, variables, or structure.",
|
||||
steps: [
|
||||
{
|
||||
step: 1,
|
||||
action: "Get current module code",
|
||||
description: "Read the current HTML, CSS, JS, and PHP of the module.",
|
||||
tool: "get_module",
|
||||
critical: "ALWAYS read the current code before modifying. Understand existing variables, structure, and styling."
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
action: "Check where it's used",
|
||||
description: "Find all pages and records using this module to understand impact.",
|
||||
tool: "check_module_usage",
|
||||
critical: "Know the blast radius of your changes — how many live pages will be affected."
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
action: "Make changes",
|
||||
description: "Update the module code with the required modifications.",
|
||||
tool: "save_module",
|
||||
critical: "Pass the module 'id' parameter to update (not create). save_module REPLACES the entire module — include ALL html/css/js, not just the changed parts."
|
||||
},
|
||||
{
|
||||
step: 4,
|
||||
action: "Update example data if needed",
|
||||
description: "If you added or renamed variables, update the example data to match.",
|
||||
tool: "set_module_example_data",
|
||||
critical: "Call get_module first to discover new variable names. Fill ALL variables, including new ones."
|
||||
},
|
||||
{
|
||||
step: 5,
|
||||
action: "Verify rendering",
|
||||
description: "Test the modified module with variable values to confirm changes work.",
|
||||
tool: "check_module",
|
||||
critical: "Test with realistic values. Compare rendering before and after changes."
|
||||
}
|
||||
],
|
||||
context: {
|
||||
builder_vars: "data-field-type attribute on elements creates editable fields. Types: textfield, headfield, textbox, wysiwyg, link, upload, uploadBackground, uploadMulti, list, multiv2.",
|
||||
component_syntax: "c-if='varname' shows/hides element. c-for='item in items' loops. c-hidden='true' invisible config. c-else after c-if.",
|
||||
save_behavior: "save_module with 'id' parameter = UPDATE. Without 'id' = CREATE new. The tool replaces the ENTIRE module code, not a diff."
|
||||
},
|
||||
rules: [
|
||||
"ALWAYS include the full html/css/js when saving — save_module replaces everything",
|
||||
"Pass the 'id' parameter to update an existing module",
|
||||
"Variable names: lowercase, no spaces, no accents",
|
||||
"Labels must be UNIQUE across the module",
|
||||
"Upload fields are ALWAYS arrays — access with [0].urlPath"
|
||||
],
|
||||
warnings: [
|
||||
"DO NOT remove existing variables without checking usage — they may have data on live pages",
|
||||
"DO NOT rename variables — it breaks existing data bindings. Add new ones instead if needed",
|
||||
"DO NOT save partial code (just HTML without CSS) — save_module replaces ALL sections",
|
||||
"DO NOT forget to update example data when adding new variables"
|
||||
],
|
||||
resources: [
|
||||
"acai://resources/guia-builder-vars",
|
||||
"acai://resources/guia-atributos-acai"
|
||||
]
|
||||
};
|
||||
48
mcp-server/tools/orchestrator/workflows/exploreSite.js
Normal file
48
mcp-server/tools/orchestrator/workflows/exploreSite.js
Normal file
@@ -0,0 +1,48 @@
|
||||
export const exploreSiteWorkflow = {
|
||||
id: "explore_site",
|
||||
name: "Explore Site",
|
||||
description: "Get an overview of the current Acai site: sections, modules, content.",
|
||||
steps: [
|
||||
{
|
||||
step: 1,
|
||||
action: "List all tables/sections",
|
||||
description: "Get the complete site structure with all sections, their types, and menu order.",
|
||||
tool: "list_tables",
|
||||
critical: "This returns the site's skeleton: all sections with type (multi/single/category/separador), menu name, and order."
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
action: "Inspect sections of interest",
|
||||
description: "Get the full schema of specific sections to understand their fields and configuration.",
|
||||
tool: "get_table_schema",
|
||||
critical: "Look at field types, required fields, list configurations, and upload fields."
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
action: "List all modules",
|
||||
description: "See all available design components/modules.",
|
||||
tool: "list_modules",
|
||||
critical: "Modules are the visual building blocks. Each has HTML, CSS, JS, and builder variables."
|
||||
},
|
||||
{
|
||||
step: 4,
|
||||
action: "Sample content",
|
||||
description: "Preview records in key sections to understand what content exists.",
|
||||
tool: "list_table_records",
|
||||
critical: "Use limit=5 to get a representative sample without overwhelming the response."
|
||||
}
|
||||
],
|
||||
context: {
|
||||
orientation: "list_tables returns all sections with their type: 'multi' (multiple records like blog/products), 'single' (one record like about page), 'category' (grouping for other sections), 'separador' (menu separator). This is the site's architecture.",
|
||||
modules_overview: "list_modules shows all components. Use get_module on specific ones to see their HTML/CSS/JS code and builder variables."
|
||||
},
|
||||
rules: [
|
||||
"Table names WITHOUT 'cms_' prefix",
|
||||
"Primary key is 'num', never 'id'"
|
||||
],
|
||||
warnings: [
|
||||
"DO NOT modify anything during exploration — this workflow is read-only",
|
||||
"DO NOT assume field names — always check the schema first"
|
||||
],
|
||||
resources: []
|
||||
};
|
||||
44
mcp-server/tools/orchestrator/workflows/index.js
Normal file
44
mcp-server/tools/orchestrator/workflows/index.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createSectionWorkflow } from "./createSection.js";
|
||||
import { populateContentWorkflow } from "./populateContent.js";
|
||||
import { createModuleWorkflow } from "./createModule.js";
|
||||
import { editModuleWorkflow } from "./editModule.js";
|
||||
import { manageRecordsWorkflow } from "./manageRecords.js";
|
||||
import { manageMediaWorkflow } from "./manageMedia.js";
|
||||
import { seoSetupWorkflow } from "./seoSetup.js";
|
||||
import { exploreSiteWorkflow } from "./exploreSite.js";
|
||||
|
||||
/**
|
||||
* Registry of all available workflows.
|
||||
* Keyed by workflow ID for fast lookup.
|
||||
*/
|
||||
export const WORKFLOWS = {
|
||||
create_section: createSectionWorkflow,
|
||||
populate_content: populateContentWorkflow,
|
||||
create_module: createModuleWorkflow,
|
||||
edit_module: editModuleWorkflow,
|
||||
manage_records: manageRecordsWorkflow,
|
||||
manage_media: manageMediaWorkflow,
|
||||
seo_setup: seoSetupWorkflow,
|
||||
explore_site: exploreSiteWorkflow,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a workflow by ID.
|
||||
* @param {string} id - Workflow identifier
|
||||
* @returns {object|null} The workflow definition or null
|
||||
*/
|
||||
export function getWorkflow(id) {
|
||||
return WORKFLOWS[id] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary list of all available workflows (for help/listing).
|
||||
*/
|
||||
export function listWorkflows() {
|
||||
return Object.values(WORKFLOWS).map((w) => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
description: w.description,
|
||||
totalSteps: w.steps.length,
|
||||
}));
|
||||
}
|
||||
53
mcp-server/tools/orchestrator/workflows/manageMedia.js
Normal file
53
mcp-server/tools/orchestrator/workflows/manageMedia.js
Normal file
@@ -0,0 +1,53 @@
|
||||
export const manageMediaWorkflow = {
|
||||
id: "manage_media",
|
||||
name: "Manage Media",
|
||||
description: "Image upload, generation, replacement, and management.",
|
||||
steps: [
|
||||
{
|
||||
step: 1,
|
||||
action: "Prepare or generate images",
|
||||
description: "Use an existing image URL/asset or generate an AI image for the content.",
|
||||
tool: "generate_image",
|
||||
critical: "generate_image uses Nano Banana AI. Existing remote image URLs can also be passed directly to upload tools."
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
action: "Upload to record",
|
||||
description: "Attach images to a record's upload field.",
|
||||
tool: "upload_record_image",
|
||||
critical: "Requires: tableName, recordId, fieldName, imageUrl. The image is downloaded server-side and attached to the record."
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
action: "List current uploads",
|
||||
description: "Check what's already uploaded in a field to know if replacing or adding.",
|
||||
tool: "list_record_uploads",
|
||||
critical: "Returns array of upload objects with uploadId needed for replace/delete operations."
|
||||
},
|
||||
{
|
||||
step: 4,
|
||||
action: "Replace or delete if needed",
|
||||
description: "Replace an existing image or delete an upload.",
|
||||
tool: "replace_record_image OR delete_record_upload",
|
||||
critical: "Both require the uploadId from list_record_uploads. replace_record_image downloads new image and swaps it."
|
||||
}
|
||||
],
|
||||
context: {
|
||||
upload_structure: "Upload fields store arrays of objects: [{urlPath, fileName, fileSize, mimeType, uploadDate}]. Access in Twig templates: record.field[0].urlPath | imagec(width).",
|
||||
image_sources: "Use existing remote image URLs, project assets, or Nano Banana AI image generation.",
|
||||
assets_upload: "upload_image_to_assets: uploads to website /images/ folder (not tied to a record). Accepts base64, data URI, or URL. Can resize and compress.",
|
||||
s3_upload: "upload_image_to_s3: uploads to Amazon S3. Returns public S3 URL. Accepts URL, local path, base64, or data URI."
|
||||
},
|
||||
rules: [
|
||||
"Table names WITHOUT 'cms_' prefix",
|
||||
"Primary key is 'num', never 'id'",
|
||||
"Upload fields are ALWAYS arrays of objects with urlPath property",
|
||||
"Use imagec filter for resizing: {{ path | imagec(width_in_pixels) }}"
|
||||
],
|
||||
warnings: [
|
||||
"DO NOT try to upload before creating the record — the record must exist first",
|
||||
"DO NOT confuse upload_record_image (attaches to record) with upload_image_to_assets (saves to /images/ folder)",
|
||||
"DO NOT delete uploads without confirming — the image will be removed from the live page"
|
||||
],
|
||||
resources: []
|
||||
};
|
||||
64
mcp-server/tools/orchestrator/workflows/manageRecords.js
Normal file
64
mcp-server/tools/orchestrator/workflows/manageRecords.js
Normal file
@@ -0,0 +1,64 @@
|
||||
export const manageRecordsWorkflow = {
|
||||
id: "manage_records",
|
||||
name: "Manage Records",
|
||||
description: "CRUD operations on existing records: query, create, update, and delete data.",
|
||||
steps: [
|
||||
{
|
||||
step: 1,
|
||||
action: "Get table schema",
|
||||
description: "Understand field names, types, and constraints before querying or modifying.",
|
||||
tool: "get_table_schema",
|
||||
critical: "Know the exact field names and types. Upload fields require special handling."
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
action: "Query records",
|
||||
description: "List or search records to find the ones to work with.",
|
||||
tool: "list_table_records",
|
||||
critical: "Use 'where' param for SQL WHERE filtering. Use 'limit' for pagination. Use 'page' for page navigation."
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
action: "Create or update records",
|
||||
description: "Create new records or update existing ones with correct field values.",
|
||||
tool: "create_or_update_record",
|
||||
critical: "Pass 'recordId' for update, omit for create. Only included fields are modified on update. Field values must match field types."
|
||||
},
|
||||
{
|
||||
step: 4,
|
||||
action: "Handle uploads if needed",
|
||||
description: "Upload images or files to record fields.",
|
||||
tool: "upload_record_image",
|
||||
critical: "Separate call per image per field per record. Cannot set upload fields via create_or_update_record."
|
||||
},
|
||||
{
|
||||
step: 5,
|
||||
action: "Verify changes",
|
||||
description: "Query the records again to confirm changes were applied correctly.",
|
||||
tool: "list_table_records",
|
||||
critical: "Confirm all fields have the expected values, including upload fields."
|
||||
}
|
||||
],
|
||||
context: {
|
||||
querying: "list_table_records supports: where='campo = \"valor\"' (SQL WHERE), page=1 (pagination), limit=20 (records per page). WHERE clause uses SQL string syntax.",
|
||||
updating: "Pass recordId + fields object to update. Only the fields included in the object are modified — other fields are left unchanged.",
|
||||
creating: "Omit recordId to create. Can batch insert by passing fields as an array of objects.",
|
||||
deleting: "delete_table_records requires tableName and recordIds (array of IDs). Use deleteAll=true to delete everything (DANGEROUS)."
|
||||
},
|
||||
rules: [
|
||||
"Table names WITHOUT 'cms_' prefix in all tool calls",
|
||||
"Primary key is ALWAYS 'num', never 'id'",
|
||||
"Upload fields CANNOT be set via create_or_update_record — use upload_record_image",
|
||||
"Date format: YYYY-MM-DD HH:mm:ss",
|
||||
"Checkbox values: 1 or 0 (number, not boolean)",
|
||||
"WHERE clauses use SQL string syntax: where='nombre = \"valor\"'"
|
||||
],
|
||||
warnings: [
|
||||
"DO NOT use 'id' to reference records — use 'num'",
|
||||
"DO NOT set upload fields via create_or_update_record — it will not work",
|
||||
"DO NOT delete records without confirming with the user first"
|
||||
],
|
||||
resources: [
|
||||
"acai://resources/guia-registros"
|
||||
]
|
||||
};
|
||||
70
mcp-server/tools/orchestrator/workflows/populateContent.js
Normal file
70
mcp-server/tools/orchestrator/workflows/populateContent.js
Normal file
@@ -0,0 +1,70 @@
|
||||
export const populateContentWorkflow = {
|
||||
id: "populate_content",
|
||||
name: "Populate Content",
|
||||
description: "Bulk record creation with images for an existing section.",
|
||||
steps: [
|
||||
{
|
||||
step: 1,
|
||||
action: "Get table schema",
|
||||
description: "Understand all fields and their types before creating records.",
|
||||
tool: "get_table_schema",
|
||||
critical: "Know the exact field names and types. Upload fields cannot be set via create_or_update_record."
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
action: "List existing records",
|
||||
description: "Check what already exists to avoid duplicates.",
|
||||
tool: "list_table_records",
|
||||
critical: "Review existing content before adding new records."
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
action: "Generate images if needed",
|
||||
description: "Create AI images for the content being created when existing assets are not available.",
|
||||
tool: "generate_image",
|
||||
critical: "Generate the image first and use the returned URL for upload later."
|
||||
},
|
||||
{
|
||||
step: 4,
|
||||
action: "Create records",
|
||||
description: "Create all records with text content. Can batch insert multiple records in one call.",
|
||||
tool: "create_or_update_record",
|
||||
critical: "Batch insert: pass an array of objects in 'fields' parameter. Date format: YYYY-MM-DD HH:mm:ss. Checkbox: 1 or 0."
|
||||
},
|
||||
{
|
||||
step: 5,
|
||||
action: "Upload images to records",
|
||||
description: "Attach images to each record's upload fields.",
|
||||
tool: "upload_record_image",
|
||||
critical: "Must call SEPARATELY for each record+field combination. Cannot batch image uploads. Need the record's num/ID from step 4."
|
||||
},
|
||||
{
|
||||
step: 6,
|
||||
action: "Verify records",
|
||||
description: "Confirm all records were created with correct data.",
|
||||
tool: "list_table_records",
|
||||
critical: "Check that all fields are populated correctly including upload fields."
|
||||
}
|
||||
],
|
||||
context: {
|
||||
batch_insert: "create_or_update_record supports batch: pass fields as an array of objects instead of a single object. Each object is one record. Returns an array of created record IDs.",
|
||||
image_sources: "Use existing project/client assets when available, or generate_image for AI-generated images via Nano Banana.",
|
||||
upload_flow: "1. Create record first (get its num/ID). 2. Then call upload_record_image with tableName, recordId, fieldName, imageUrl. 3. The image is downloaded server-side and attached to the record."
|
||||
},
|
||||
rules: [
|
||||
"Table names WITHOUT 'cms_' prefix in all tool calls",
|
||||
"Primary key is ALWAYS 'num', never 'id'",
|
||||
"Upload fields CANNOT be set via create_or_update_record — use upload_record_image",
|
||||
"Date format: YYYY-MM-DD HH:mm:ss",
|
||||
"Checkbox values: 1 or 0 (number, not boolean)",
|
||||
"Enlace field: auto-formatted to /path/ with slashes if not provided"
|
||||
],
|
||||
warnings: [
|
||||
"DO NOT try to set upload field values in create_or_update_record — use upload_record_image after creation",
|
||||
"DO NOT forget that batch insert returns an array of created record IDs — you need these for image uploads",
|
||||
"DO NOT upload images before creating the record — the record must exist first"
|
||||
],
|
||||
resources: [
|
||||
"acai://resources/guia-registros"
|
||||
]
|
||||
};
|
||||
58
mcp-server/tools/orchestrator/workflows/seoSetup.js
Normal file
58
mcp-server/tools/orchestrator/workflows/seoSetup.js
Normal file
@@ -0,0 +1,58 @@
|
||||
export const seoSetupWorkflow = {
|
||||
id: "seo_setup",
|
||||
name: "SEO Setup",
|
||||
description: "Configure SEO for a section: meta tags, URL slugs, and structured data.",
|
||||
steps: [
|
||||
{
|
||||
step: 1,
|
||||
action: "Get current table schema",
|
||||
description: "Check if seo_metas is already enabled and if enlace (URL slug) exists.",
|
||||
tool: "get_table_schema",
|
||||
critical: "Look for seo_metas flag and enlace configuration 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."
|
||||
},
|
||||
{
|
||||
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."
|
||||
},
|
||||
{
|
||||
step: 4,
|
||||
action: "Update records with SEO data",
|
||||
description: "Fill in SEO fields for each record: meta title, meta description.",
|
||||
tool: "create_or_update_record",
|
||||
critical: "SEO fields are typically: seo_title, seo_description. Check the schema for exact field names."
|
||||
},
|
||||
{
|
||||
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."
|
||||
}
|
||||
],
|
||||
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."
|
||||
},
|
||||
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"
|
||||
],
|
||||
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"
|
||||
],
|
||||
resources: []
|
||||
};
|
||||
5
mcp-server/tools/project/index.js
Normal file
5
mcp-server/tools/project/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { registerSaveProjectStylesTool } from "./saveStyles.js";
|
||||
|
||||
export function registerProjectTools(server) {
|
||||
registerSaveProjectStylesTool(server);
|
||||
}
|
||||
61
mcp-server/tools/project/saveStyles.js
Normal file
61
mcp-server/tools/project/saveStyles.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth } from "../../auth/index.js";
|
||||
import { handleToolError } from "../helpers/errorHandler.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export function registerSaveProjectStylesTool(server) {
|
||||
server.tool(
|
||||
"save_project_styles",
|
||||
`Save the project's visual design styles summary to docs/project-styles.md. Call this after exploring existing modules to cache the style reference for future module creation. This avoids re-exploring modules every time a new module is created.
|
||||
|
||||
The content should include: color palette (hex values), typography, spacing patterns, Tailwind classes, button/card/section styles, and recurring design patterns.`,
|
||||
withAuthParams({
|
||||
content: z.string().describe("Markdown content with the project styles summary"),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ content }, extra) => {
|
||||
try {
|
||||
if (!content || !content.trim()) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: content is required" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Get project directory from env
|
||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
||||
if (!projectDir) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: ACAI_PROJECT_DIR not set" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure docs/ directory exists
|
||||
const docsDir = path.join(projectDir, "docs");
|
||||
if (!fs.existsSync(docsDir)) {
|
||||
fs.mkdirSync(docsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write the file
|
||||
const filePath = path.join(docsDir, "project-styles.md");
|
||||
fs.writeFileSync(filePath, content.trim() + "\n", "utf-8");
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: "Project styles saved successfully",
|
||||
filePath: "docs/project-styles.md",
|
||||
})
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, "save_project_styles", {});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
73
mcp-server/tools/records/addModuleToRecord.js
Normal file
73
mcp-server/tools/records/addModuleToRecord.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleApiResponse, handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
|
||||
export function registerAddModuleToRecordTool(server) {
|
||||
server.tool(
|
||||
"add_module_to_record",
|
||||
`Adds a builder module to a specific record at the desired position. Returns the generated sectionId — use it directly with set_module_config_vars without needing to call list_page_modules.
|
||||
|
||||
Required params:
|
||||
- tableName (string) without 'cms_' prefix
|
||||
- recordNum (number) record primary key ('num' field, never 'id')
|
||||
- moduleId (string) module identifier
|
||||
Optional:
|
||||
- modulePosition (number) insertion index (0-based, default 0)
|
||||
|
||||
Response includes: sectionId, moduleId, position, totalModules`,
|
||||
withAuthParams({
|
||||
tableName: z.string().describe("Table name without cms_ prefix, e.g. 'apartados'"),
|
||||
recordNum: z.union([z.string(), z.number()]).describe("Record num (ID) where the module will be inserted"),
|
||||
moduleId: z.string().describe("Module ID to insert"),
|
||||
modulePosition: z.number().optional().describe("Position in the builder array (0-based). Default 0.")
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ tableName, recordNum, moduleId, modulePosition }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired({ tableName, recordNum, moduleId }, ['tableName', 'recordNum', 'moduleId'], 'add_module_to_record');
|
||||
if (validationError) return validationError;
|
||||
|
||||
const sessionId = extra.sessionId;
|
||||
const credentials = await getSessionCredentials(sessionId);
|
||||
const payload = {
|
||||
tableName,
|
||||
recordNum,
|
||||
moduleId,
|
||||
modulePosition: modulePosition ?? 0
|
||||
};
|
||||
|
||||
// Same endpoint pattern as create_or_update_record: cmsApi subaction
|
||||
const response = await AcaiHttpClient.addModuleToRecord(
|
||||
credentials,
|
||||
credentials.token,
|
||||
credentials.tokenHash,
|
||||
payload
|
||||
);
|
||||
|
||||
const apiError = handleApiResponse(response.data, 'add_module_to_record');
|
||||
if (apiError) return apiError;
|
||||
|
||||
const result = response.data || {}
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
action: 'add_module_to_record',
|
||||
tableName,
|
||||
recordNum,
|
||||
moduleId,
|
||||
sectionId: result.sectionId,
|
||||
position: result.position ?? (modulePosition ?? 0),
|
||||
totalModules: result.totalModules,
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'add_module_to_record', { tableName, recordNum, moduleId });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
145
mcp-server/tools/records/createUpdate.js
Normal file
145
mcp-server/tools/records/createUpdate.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
|
||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
||||
import { table } from "console";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
|
||||
export function registerCreateOrUpdateRecordTool(server) {
|
||||
server.tool(
|
||||
"create_or_update_record",
|
||||
`Create or update records in a database table. Before using: read resource 'acai-cheat-sheet' for domain rules, then check table schema with get_table_schema.
|
||||
|
||||
Key rules: tables without 'cms_' prefix, primary key is 'num', uploads are arrays (use upload_record_image after creating record), dates as YYYY-MM-DD HH:mm:ss, checkboxes as 1/0, enlace as /path/.
|
||||
|
||||
For builder tables (e.g. 'apartados'): must include num:null, builder:"[]", controlador, precontrolador, breadcrumb, enlace fields. See resource 'guia-registros' for full field type reference.`,
|
||||
withAuthParams({
|
||||
tableName: z.string().describe("Name of the table (without 'cms_' prefix, e.g., 'productos', 'equipo')"),
|
||||
recordId: z.any().optional().describe("Record ID for updating. Leave empty to create new record. NOT USED when records is an array."),
|
||||
fields: z.any().describe("Single record object OR array of record objects for batch insert. Example: { nombre: 'Product 1' } or [{ nombre: 'Product 1' }, { nombre: 'Product 2' }]. IMPORTANT: Always consult 'guia-registros' for field types and formats and check if is table with builder fields."),
|
||||
tableSchema: z.any().describe("Provide the table schema object to validate field types before sending to API. If not provided, schema will not be validated."),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ tableName, recordId, fields }, extra) => {
|
||||
try {
|
||||
// Validate required parameters
|
||||
const validationError = validateRequired({ tableName, fields }, ['tableName', 'fields'], 'create_or_update_record');
|
||||
if (validationError) return validationError;
|
||||
|
||||
// if fields is string, try to parse as JSON
|
||||
if (typeof fields === 'string') {
|
||||
try {
|
||||
fields = JSON.parse(fields);
|
||||
} catch (e) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: 'fields' parameter is a string but not valid JSON." }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Determine if fields is array or single object
|
||||
const isArray = Array.isArray(fields);
|
||||
const recordsArray = isArray ? fields : [fields];
|
||||
|
||||
// Check if trying to update with array (not supported)
|
||||
if (isArray && recordId) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: Cannot use recordId when fields is an array. Use fields as array for batch insert only." }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Protect critical fields during updates — these should never be changed by AI
|
||||
const PROTECTED_UPDATE_FIELDS = ['enlace', 'controlador', 'precontrolador'];
|
||||
if (recordId) {
|
||||
// On update: strip protected fields silently
|
||||
recordsArray.forEach(record => {
|
||||
PROTECTED_UPDATE_FIELDS.forEach(f => {
|
||||
if (f in record) delete record[f];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Process enlace field for new records only
|
||||
let processedRecords = recordsArray;
|
||||
if (!recordId) {
|
||||
processedRecords = recordsArray.map(record => {
|
||||
let enlaceValue = record.enlace;
|
||||
|
||||
if (!enlaceValue) {
|
||||
// Generate random enlace if not provided to ensure uniqueness
|
||||
enlaceValue = '/' + Math.random().toString(36).substring(2, 10) + '/';
|
||||
} else {
|
||||
// Ensure format /.../
|
||||
enlaceValue = String(enlaceValue);
|
||||
if (!enlaceValue.startsWith('/')) enlaceValue = '/' + enlaceValue;
|
||||
if (!enlaceValue.endsWith('/')) enlaceValue = enlaceValue + '/';
|
||||
}
|
||||
|
||||
return { ...record, enlace: enlaceValue };
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare payload for CMS API
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
const recordPayload = {
|
||||
tableName: tableName,
|
||||
records: processedRecords,
|
||||
functions: [],
|
||||
options: {}
|
||||
};
|
||||
|
||||
// Determine action: insert for new records, update for existing
|
||||
const isNewRecord = !recordId;
|
||||
let response;
|
||||
|
||||
if (isNewRecord) {
|
||||
// Insert new record(s)
|
||||
response = await AcaiHttpClient.postCmsApi(
|
||||
credentials,
|
||||
'insert',
|
||||
recordPayload,
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
);
|
||||
} else {
|
||||
// Update existing record (only single record, not array)
|
||||
response = await AcaiHttpClient.postCmsApi(
|
||||
credentials,
|
||||
'update',
|
||||
{
|
||||
...recordPayload,
|
||||
where: `num = ${recordId}`
|
||||
},
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
);
|
||||
}
|
||||
|
||||
// Check for API errors
|
||||
const apiError = handleApiResponse(response.data, 'create_or_update_record');
|
||||
if (apiError) return apiError;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: isNewRecord
|
||||
? `${isArray ? recordsArray.length : 1} record(s) created successfully`
|
||||
: `Record ${recordId} updated successfully`,
|
||||
tableName: tableName,
|
||||
recordIds: response.data?.data || (recordId || 'new'),
|
||||
recordsCount: isArray ? recordsArray.length : 1,
|
||||
createdIds: response.data?.data,
|
||||
suggestion: isNewRecord && !isArray ? `You can verify the record by fetching: ${credentials.web_url}${processedRecords[0].enlace}` : undefined
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'create_or_update_record', { tableName, recordId, isArray: Array.isArray(fields) });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
89
mcp-server/tools/records/delete.js
Normal file
89
mcp-server/tools/records/delete.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
|
||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
|
||||
export function registerDeleteTableRecordsTool(server) {
|
||||
server.tool(
|
||||
"delete_table_records",
|
||||
"⚠️ DANGEROUS: Delete records from a database table. This is a PERMANENT operation that cannot be undone. Use with extreme caution. You can delete specific records by their 'num' (primary key) or delete all records from a table. Table names are WITHOUT the 'cms_' prefix.",
|
||||
withAuthParams({
|
||||
tableName: z.string().describe("Name of the table to delete records from (without 'cms_' prefix)"),
|
||||
recordIds: z.array(z.union([z.string(), z.number()])).optional().describe("Array of record 'num' values (primary key) to delete. If not provided, you must set deleteAll to true."),
|
||||
deleteAll: z.boolean().optional().describe("Set to true to delete ALL records from the table. Use with extreme caution."),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: true },
|
||||
withAuth(async ({ tableName, recordIds, deleteAll = false }, extra) => {
|
||||
try {
|
||||
// Validation: must provide either recordIds or deleteAll
|
||||
const validationError = validateRequired({ tableName }, ['tableName'], 'delete_table_records');
|
||||
if (validationError) return validationError;
|
||||
|
||||
if (!recordIds && !deleteAll) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: You must provide either 'recordIds' or set 'deleteAll' to true." }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (deleteAll && recordIds) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: Cannot specify both 'recordIds' and 'deleteAll'. Choose one." }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (deleteAll) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: 'deleteAll' is not currently supported with this method. Please provide 'recordIds'." }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Build delete parameters for CMS API
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
|
||||
// Build SQL where clause for deleting multiple records
|
||||
const whereClause = recordIds.length === 1
|
||||
? `num = ${recordIds[0]}`
|
||||
: `num IN (${recordIds.join(',')})`;
|
||||
|
||||
const payload = {
|
||||
tableName: tableName,
|
||||
where: whereClause,
|
||||
options: {}
|
||||
};
|
||||
|
||||
// Send to CMS API via viewer_functions
|
||||
const response = await AcaiHttpClient.postCmsApi(
|
||||
credentials,
|
||||
'delete',
|
||||
payload,
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
);
|
||||
|
||||
// Check for API errors
|
||||
const apiError = handleApiResponse(response.data, 'delete_table_records');
|
||||
if (apiError) return apiError;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: `${recordIds.length} record(s) deleted from table '${tableName}'`,
|
||||
deletedCount: recordIds.length,
|
||||
tableName: tableName,
|
||||
serverResponse: response.data ? "Response received" : "No response body"
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'delete_table_records', { tableName, recordCount: recordIds?.length || 0 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
63
mcp-server/tools/records/getModuleConfigVars.js
Normal file
63
mcp-server/tools/records/getModuleConfigVars.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleApiResponse, handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
|
||||
export function registerGetModuleConfigVarsTool(server) {
|
||||
server.tool(
|
||||
"get_module_config_vars",
|
||||
`Get the current configuration variable values for a module instance on a page record. Returns resolved values (text, HTML, etc.) for simple vars and arrays of objects for multi/repeater vars.
|
||||
|
||||
Required params:
|
||||
- tableName (string) without 'cms_' prefix
|
||||
- recordNum (number) record primary key ('num' field, never 'id')
|
||||
- sectionId (string) section ID of the module instance`,
|
||||
withAuthParams({
|
||||
tableName: z.string().describe("Parent table name (e.g. 'apartados')"),
|
||||
recordNum: z.number().describe("Parent record number"),
|
||||
sectionId: z.string().describe("Section ID of the module instance"),
|
||||
}),
|
||||
{ readOnlyHint: true, destructiveHint: false },
|
||||
withAuth(async ({ tableName, recordNum, sectionId }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired({ tableName, recordNum, sectionId }, ['tableName', 'recordNum', 'sectionId'], 'get_module_config_vars');
|
||||
if (validationError) return validationError;
|
||||
|
||||
const sessionId = extra.sessionId;
|
||||
const credentials = await getSessionCredentials(sessionId);
|
||||
const payload = {
|
||||
tableName,
|
||||
recordNum,
|
||||
sectionId
|
||||
};
|
||||
|
||||
const response = await AcaiHttpClient.getModuleConfigVars(
|
||||
credentials,
|
||||
credentials.token,
|
||||
credentials.tokenHash,
|
||||
payload
|
||||
);
|
||||
|
||||
const apiError = handleApiResponse(response.data, 'get_module_config_vars');
|
||||
if (apiError) return apiError;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
action: 'get_module_config_vars',
|
||||
tableName,
|
||||
recordNum,
|
||||
sectionId,
|
||||
data: response.data?.data ?? response.data
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'get_module_config_vars', { tableName, recordNum, sectionId });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
96
mcp-server/tools/records/getRecord.js
Normal file
96
mcp-server/tools/records/getRecord.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
|
||||
export function registerGetRecordTool(server) {
|
||||
server.tool(
|
||||
"get_record",
|
||||
`Get a single record by its 'num' (primary key) with full details including uploads and relations.
|
||||
|
||||
Table names: use WITHOUT 'cms_' prefix for tables that have a schema in cms/data/schema/.
|
||||
For tables WITHOUT schema (system tables, custom tables), pass the EXACT full table name including 'cms_' prefix.
|
||||
|
||||
Examples:
|
||||
- "productos" (has schema) → fetches from cms_productos automatically
|
||||
- "cms_uploads" (no schema) → fetches with exact name, no prefix added`,
|
||||
withAuthParams({
|
||||
tableName: z.string().describe("Table name. Without 'cms_' prefix if it has schema, or exact name with 'cms_' prefix if no schema."),
|
||||
recordNum: z.string().describe("Record 'num' (primary key)"),
|
||||
loadUploads: z.boolean().optional().default(true).describe("Load upload field data (default: true)"),
|
||||
loadRelations: z.boolean().optional().default(true).describe("Resolve foreign key relations (default: true)"),
|
||||
}),
|
||||
{ readOnlyHint: true, destructiveHint: false },
|
||||
withAuth(async ({ tableName, recordNum, loadUploads = true, loadRelations = true }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired(
|
||||
{ tableName, recordNum },
|
||||
['tableName', 'recordNum'],
|
||||
'get_record'
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
|
||||
// Detect if table name has cms_ prefix (no schema table)
|
||||
const noSchema = tableName.startsWith("cms_");
|
||||
|
||||
const payload = {
|
||||
tableName,
|
||||
where: noSchema ? `num=${recordNum}` : `num=${recordNum}`,
|
||||
limit: 1,
|
||||
options: {
|
||||
uploads: loadUploads,
|
||||
relations: loadRelations,
|
||||
relationsDepth: 2,
|
||||
},
|
||||
};
|
||||
|
||||
// Tables without schema need special options
|
||||
if (noSchema) {
|
||||
payload.options.prefix = "";
|
||||
payload.options.ignoreSchema = true;
|
||||
}
|
||||
|
||||
const response = await AcaiHttpClient.postCmsApi(
|
||||
credentials,
|
||||
"get",
|
||||
payload,
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
);
|
||||
|
||||
const records = response.data?.data || [];
|
||||
|
||||
if (records.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `Record num=${recordNum} not found in table '${tableName}'`,
|
||||
hint: noSchema
|
||||
? "Table queried with exact name (no schema mode)."
|
||||
: "If the table has no schema, try with the full name including 'cms_' prefix."
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
tableName,
|
||||
record: records[0],
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'get_record', { tableName, recordNum });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
26
mcp-server/tools/records/index.js
Normal file
26
mcp-server/tools/records/index.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { registerListTableRecordsTool } from './list.js';
|
||||
import { registerGetRecordTool } from './getRecord.js';
|
||||
import { registerCreateOrUpdateRecordTool } from './createUpdate.js';
|
||||
import { registerDeleteTableRecordsTool } from './delete.js';
|
||||
import { registerAddModuleToRecordTool } from './addModuleToRecord.js';
|
||||
import { registerRemoveModuleFromRecordTool } from './removeModuleFromRecord.js';
|
||||
import { registerListPageModulesTool } from './listPageModules.js';
|
||||
import { registerReorderModuleTool } from './reorderModule.js';
|
||||
import { registerToggleModuleVisibilityTool } from './toggleModuleVisibility.js';
|
||||
import { registerSetModuleConfigVarsTool } from './setModuleConfigVars.js';
|
||||
import { registerGetModuleConfigVarsTool } from './getModuleConfigVars.js';
|
||||
|
||||
export function registerRecordTools(server) {
|
||||
registerListTableRecordsTool(server);
|
||||
registerGetRecordTool(server);
|
||||
registerCreateOrUpdateRecordTool(server);
|
||||
registerDeleteTableRecordsTool(server);
|
||||
registerAddModuleToRecordTool(server);
|
||||
registerRemoveModuleFromRecordTool(server);
|
||||
registerListPageModulesTool(server);
|
||||
registerReorderModuleTool(server);
|
||||
registerToggleModuleVisibilityTool(server);
|
||||
registerSetModuleConfigVarsTool(server);
|
||||
registerGetModuleConfigVarsTool(server);
|
||||
}
|
||||
|
||||
89
mcp-server/tools/records/list.js
Normal file
89
mcp-server/tools/records/list.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
|
||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
|
||||
export function registerListTableRecordsTool(server) {
|
||||
server.tool(
|
||||
"list_table_records",
|
||||
"List or search records in a database table. Returns JSON. Default limit is 50 — request only what you need. Use 'fields' to return only the columns you need (saves tokens). ALWAYS use 'num' as primary key, NEVER 'id'. Upload fields are arrays with urlPath.",
|
||||
withAuthParams({
|
||||
tableName: z.string().describe("Name of the table (without 'cms_' prefix, e.g., 'productos')"),
|
||||
page: z.number().optional().describe("Page number (default: 1)"),
|
||||
where: z.string().optional().describe("SQL WHERE clause to filter records (e.g., \"name LIKE '%keyword%'\")"),
|
||||
limit: z.number().optional().describe("Max records to return. Default: 50. Use 5-10 for previews, up to 200 max for large exports."),
|
||||
fields: z.array(z.string()).optional().describe("Return only these columns (e.g., ['num', 'titulo', 'precio']). Omit to return all columns. Always include 'num' if you need record IDs."),
|
||||
truncateText: z.number().optional().describe("Truncate string field values longer than this many chars. Appends '... [truncated, N chars]'. Combine with 'fields' for maximum token savings."),
|
||||
}),
|
||||
{ readOnlyHint: true, destructiveHint: false },
|
||||
withAuth(async ({ tableName, page, where, limit, fields, truncateText }, extra) => {
|
||||
try {
|
||||
// Validate required parameters
|
||||
const validationError = validateRequired({ tableName }, ['tableName'], 'list_table_records');
|
||||
if (validationError) return validationError;
|
||||
|
||||
// Build payload for CMS API
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
const payload = {
|
||||
tableName: tableName,
|
||||
where: where || "",
|
||||
order: "",
|
||||
limit: limit || 50,
|
||||
options: {}
|
||||
};
|
||||
|
||||
// Send to CMS API via viewer_functions
|
||||
const response = await AcaiHttpClient.postCmsApi(
|
||||
credentials,
|
||||
'get',
|
||||
payload,
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
);
|
||||
|
||||
// Check for API errors
|
||||
const apiError = handleApiResponse(response.data, 'list_table_records');
|
||||
if (apiError) return apiError;
|
||||
|
||||
// Post-process: filter fields if requested
|
||||
let resultData = response.data;
|
||||
if (fields && fields.length > 0 && Array.isArray(resultData?.data)) {
|
||||
resultData = {
|
||||
...resultData,
|
||||
data: resultData.data.map(record =>
|
||||
Object.fromEntries(fields.map(f => [f, record[f]]))
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
// Post-process: truncate long text values if requested
|
||||
if (truncateText && truncateText > 0 && Array.isArray(resultData?.data)) {
|
||||
resultData = {
|
||||
...resultData,
|
||||
data: resultData.data.map(record => {
|
||||
const truncated = {};
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (typeof value === 'string' && value.length > truncateText) {
|
||||
truncated[key] = value.substring(0, truncateText) + `... [truncated, ${value.length} chars]`;
|
||||
} else {
|
||||
truncated[key] = value;
|
||||
}
|
||||
}
|
||||
return truncated;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify(resultData, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'list_table_records', { tableName, page });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
118
mcp-server/tools/records/listPageModules.js
Normal file
118
mcp-server/tools/records/listPageModules.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
|
||||
export function registerListPageModulesTool(server) {
|
||||
server.tool(
|
||||
"list_page_modules",
|
||||
`List all builder modules placed on a page/record. Returns module IDs, section_ids, positions, visibility, and config-vars.
|
||||
|
||||
Use this to understand the current layout of a page before adding, removing, or reordering modules.
|
||||
|
||||
Table names WITHOUT 'cms_' prefix. The recordNum is the 'num' primary key.
|
||||
Common table for pages: 'apartados'.`,
|
||||
withAuthParams({
|
||||
tableName: z.string().describe("Table name without cms_ prefix (e.g. 'apartados')"),
|
||||
recordNum: z.union([z.string(), z.number()]).describe("Record num (primary key)"),
|
||||
}),
|
||||
{ readOnlyHint: true, destructiveHint: false },
|
||||
withAuth(async ({ tableName, recordNum }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired(
|
||||
{ tableName, recordNum },
|
||||
['tableName', 'recordNum'],
|
||||
'list_page_modules'
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
|
||||
const response = await AcaiHttpClient.postCmsApi(
|
||||
credentials,
|
||||
"get",
|
||||
{
|
||||
tableName,
|
||||
where: `num=${recordNum}`,
|
||||
limit: 1,
|
||||
options: { uploads: false, relations: false },
|
||||
},
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
);
|
||||
|
||||
const records = response.data?.data || [];
|
||||
if (records.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `Record num=${recordNum} not found in table '${tableName}'`,
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const record = records[0];
|
||||
const builderRaw = record.builder || "[]";
|
||||
|
||||
let builderData;
|
||||
try {
|
||||
builderData = JSON.parse(builderRaw);
|
||||
} catch {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: "Could not parse builder JSON",
|
||||
raw: builderRaw.substring(0, 500),
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.isArray(builderData)) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: "Builder field is not an array",
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const modules = builderData.map((mod, index) => ({
|
||||
position: index,
|
||||
moduleId: mod.modulo || null,
|
||||
sectionId: mod.section_id || null,
|
||||
hidden: !!mod.oculto,
|
||||
configVars: mod["config-vars"] || {},
|
||||
}));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
tableName,
|
||||
recordNum,
|
||||
recordTitle: record.titulo || record.name || record.nombre || null,
|
||||
recordEnlace: record.enlace || null,
|
||||
modulesCount: modules.length,
|
||||
modules,
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'list_page_modules', { tableName, recordNum });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
78
mcp-server/tools/records/removeModuleFromRecord.js
Normal file
78
mcp-server/tools/records/removeModuleFromRecord.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getSessionCredentials } 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 registerRemoveModuleFromRecordTool(server) {
|
||||
server.tool(
|
||||
"remove_module_from_record",
|
||||
`Removes a builder module from a record's builder array.
|
||||
|
||||
Identify the module to remove by either:
|
||||
- sectionId (unique, preferred) — get it from the builder JSON of the record
|
||||
- modulePosition (0-based index) — position in the builder array
|
||||
|
||||
Required: tableName + recordNum + (sectionId OR modulePosition)`,
|
||||
withAuthParams({
|
||||
tableName: z.string().describe("Table name without cms_ prefix, e.g. 'apartados'"),
|
||||
recordNum: z.union([z.string(), z.number()]).describe("Record num (primary key)"),
|
||||
sectionId: z.string().optional().describe("section_id of the module to remove (preferred)"),
|
||||
modulePosition: z.number().optional().describe("Position in builder array (0-based). Use if sectionId not available."),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: true },
|
||||
withAuth(async ({ tableName, recordNum, sectionId, modulePosition }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired(
|
||||
{ tableName, recordNum },
|
||||
['tableName', 'recordNum'],
|
||||
'remove_module_from_record'
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
if (!sectionId && modulePosition === undefined) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: sectionId or modulePosition is required" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
const payload = {
|
||||
tableName,
|
||||
recordNum,
|
||||
};
|
||||
if (sectionId) payload.sectionId = sectionId;
|
||||
if (modulePosition !== undefined) payload.modulePosition = modulePosition;
|
||||
|
||||
const response = await AcaiHttpClient.postViewerAction(
|
||||
credentials,
|
||||
"removeModuleFromRecord",
|
||||
payload,
|
||||
credentials.token,
|
||||
credentials.tokenHash,
|
||||
{},
|
||||
15000
|
||||
);
|
||||
|
||||
const apiError = handleApiResponse(response.data, 'remove_module_from_record');
|
||||
if (apiError) return apiError;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
action: 'remove_module_from_record',
|
||||
tableName,
|
||||
recordNum,
|
||||
...response.data,
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'remove_module_from_record', { tableName, recordNum, sectionId, modulePosition });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
64
mcp-server/tools/records/reorderModule.js
Normal file
64
mcp-server/tools/records/reorderModule.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getSessionCredentials } 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 registerReorderModuleTool(server) {
|
||||
server.tool(
|
||||
"reorder_module",
|
||||
`Move a module from one position to another in a record's builder array.
|
||||
Use list_page_modules first to see current positions.
|
||||
|
||||
Table names WITHOUT 'cms_' prefix. The recordNum is the 'num' primary key.`,
|
||||
withAuthParams({
|
||||
tableName: z.string().describe("Table name without cms_ prefix (e.g. 'apartados')"),
|
||||
recordNum: z.union([z.string(), z.number()]).describe("Record num (primary key)"),
|
||||
fromPosition: z.number().describe("Current position of the module (0-based)"),
|
||||
toPosition: z.number().describe("Target position to move the module to (0-based)"),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ tableName, recordNum, fromPosition, toPosition }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired(
|
||||
{ tableName, recordNum },
|
||||
['tableName', 'recordNum'],
|
||||
'reorder_module'
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
const response = await AcaiHttpClient.postViewerAction(
|
||||
credentials,
|
||||
"reorderModule",
|
||||
{
|
||||
tableName,
|
||||
recordNum,
|
||||
fromPosition,
|
||||
toPosition,
|
||||
},
|
||||
credentials.token,
|
||||
credentials.tokenHash,
|
||||
{},
|
||||
15000
|
||||
);
|
||||
|
||||
const apiError = handleApiResponse(response.data, 'reorder_module');
|
||||
if (apiError) return apiError;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
action: 'reorder_module',
|
||||
...response.data,
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'reorder_module', { tableName, recordNum, fromPosition, toPosition });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
70
mcp-server/tools/records/setModuleConfigVars.js
Normal file
70
mcp-server/tools/records/setModuleConfigVars.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleApiResponse, handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
|
||||
export function registerSetModuleConfigVarsTool(server) {
|
||||
server.tool(
|
||||
"set_module_config_vars",
|
||||
`Set configuration variables for a module instance on a page record. Supports simple vars (text, list, checkbox, colorpicker, etc.) and multi/repeater vars (records array). For simple vars, pass key-value pairs. For multi vars, pass an array of objects with sub-var values.
|
||||
|
||||
All field types are passed the same way as string values. Fields like list, checkbox and colorpicker are stored directly in config-vars (not in builder_custom). Text, title, wysiwyg and upload fields are stored in builder_custom automatically.
|
||||
|
||||
The response includes 'uploadFields' — a map of upload variable names to their recordNum and fieldName. Use these directly with upload_record_image (tableName="builder_custom") without needing to read builder.json. For multi vars with uploads, the key is "varName.subVarName" and the value is an array of {index, fieldName, recordNum}.
|
||||
|
||||
Required params:
|
||||
- tableName (string) without 'cms_' prefix
|
||||
- recordNum (number) record primary key ('num' field, never 'id')
|
||||
- sectionId (string) section ID of the module instance
|
||||
- vars (object) variable names as keys`,
|
||||
withAuthParams({
|
||||
tableName: z.string().describe("Parent table name (e.g. 'apartados')"),
|
||||
recordNum: z.number().describe("Parent record number"),
|
||||
sectionId: z.string().describe("Section ID of the module instance"),
|
||||
vars: z.record(z.any()).describe("Object with variable names as keys. Simple vars: string values. Multi vars: array of objects with sub-var values. Example: { titulo: 'My Title', records: [{ pregunta: 'Q1', respuesta: 'A1' }] }")
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ tableName, recordNum, sectionId, vars }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired({ tableName, recordNum, sectionId, vars }, ['tableName', 'recordNum', 'sectionId', 'vars'], 'set_module_config_vars');
|
||||
if (validationError) return validationError;
|
||||
|
||||
const sessionId = extra.sessionId;
|
||||
const credentials = await getSessionCredentials(sessionId);
|
||||
const payload = {
|
||||
tableName,
|
||||
recordNum,
|
||||
sectionId,
|
||||
vars
|
||||
};
|
||||
|
||||
const response = await AcaiHttpClient.setModuleConfigVars(
|
||||
credentials,
|
||||
credentials.token,
|
||||
credentials.tokenHash,
|
||||
payload
|
||||
);
|
||||
|
||||
const apiError = handleApiResponse(response.data, 'set_module_config_vars');
|
||||
if (apiError) return apiError;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
action: 'set_module_config_vars',
|
||||
tableName,
|
||||
recordNum,
|
||||
sectionId,
|
||||
data: response.data?.data ?? response.data
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'set_module_config_vars', { tableName, recordNum, sectionId });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
76
mcp-server/tools/records/toggleModuleVisibility.js
Normal file
76
mcp-server/tools/records/toggleModuleVisibility.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getSessionCredentials } 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 registerToggleModuleVisibilityTool(server) {
|
||||
server.tool(
|
||||
"toggle_module_visibility",
|
||||
`Show or hide a module on a page without removing it.
|
||||
Identify the module by sectionId (preferred) or modulePosition.
|
||||
Optionally set visible=true/false explicitly, or omit to toggle.
|
||||
|
||||
Table names WITHOUT 'cms_' prefix. The recordNum is the 'num' primary key.`,
|
||||
withAuthParams({
|
||||
tableName: z.string().describe("Table name without cms_ prefix (e.g. 'apartados')"),
|
||||
recordNum: z.union([z.string(), z.number()]).describe("Record num (primary key)"),
|
||||
sectionId: z.string().optional().describe("section_id of the module (preferred)"),
|
||||
modulePosition: z.number().optional().describe("Position in builder array (0-based)"),
|
||||
visible: z.boolean().optional().describe("Set explicitly: true=show, false=hide. Omit to toggle."),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ tableName, recordNum, sectionId, modulePosition, visible }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired(
|
||||
{ tableName, recordNum },
|
||||
['tableName', 'recordNum'],
|
||||
'toggle_module_visibility'
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
if (!sectionId && modulePosition === undefined) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: sectionId or modulePosition is required" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
const payload = {
|
||||
tableName,
|
||||
recordNum,
|
||||
};
|
||||
if (sectionId) payload.sectionId = sectionId;
|
||||
if (modulePosition !== undefined) payload.modulePosition = modulePosition;
|
||||
if (visible !== undefined) payload.visible = visible;
|
||||
|
||||
const response = await AcaiHttpClient.postViewerAction(
|
||||
credentials,
|
||||
"toggleModuleVisibility",
|
||||
payload,
|
||||
credentials.token,
|
||||
credentials.tokenHash,
|
||||
{},
|
||||
15000
|
||||
);
|
||||
|
||||
const apiError = handleApiResponse(response.data, 'toggle_module_visibility');
|
||||
if (apiError) return apiError;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
action: 'toggle_module_visibility',
|
||||
...response.data,
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'toggle_module_visibility', { tableName, recordNum, sectionId, modulePosition });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
5
mcp-server/tools/remote_git/index.js
Normal file
5
mcp-server/tools/remote_git/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { registerRecoverGitTools } from './rollback.js';
|
||||
|
||||
export function registerRemoteGitTools(server) {
|
||||
registerRecoverGitTools(server);
|
||||
}
|
||||
483
mcp-server/tools/remote_git/rollback.js
Normal file
483
mcp-server/tools/remote_git/rollback.js
Normal file
@@ -0,0 +1,483 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getApiClient, getCommonParams } from "../../auth/index.js";
|
||||
import { handleToolError, handleApiResponse, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
|
||||
const COMMIT_HASH_REGEX = /\b[a-f0-9]{40}\b/i;
|
||||
const MAX_LOG_LIMIT = 20;
|
||||
const lastModulePathBySession = new Map();
|
||||
const LAYOUT_CONTEXT_PATH = "/modulos/layout/";
|
||||
const LAYOUT_ALIASES = new Set([
|
||||
"layout",
|
||||
"header",
|
||||
"footer",
|
||||
"hookglobal",
|
||||
"hooksglobal",
|
||||
"globalhook",
|
||||
"globalhooks",
|
||||
"hooksglobales"
|
||||
]);
|
||||
|
||||
function normalizePath(path) {
|
||||
const trimmedPath = path.trim();
|
||||
if (!trimmedPath) {
|
||||
return trimmedPath;
|
||||
}
|
||||
|
||||
const cleanPath = trimmedPath.replace(/^\/+|\/+$/g, "");
|
||||
if (!cleanPath) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
const normalizedToken = cleanPath.toLowerCase().replace(/[\s_-]+/g, "");
|
||||
if (LAYOUT_ALIASES.has(normalizedToken) || cleanPath.toLowerCase() === "modulos/layout") {
|
||||
return LAYOUT_CONTEXT_PATH;
|
||||
}
|
||||
|
||||
// Common case: only module id/name provided (e.g. nameofthecustommodule_aeq9kl)
|
||||
if (!cleanPath.includes("/")) {
|
||||
return `/modulos/${cleanPath}/`;
|
||||
}
|
||||
|
||||
// If "modulos/xxx" is provided, normalize with leading/trailing slash.
|
||||
if (cleanPath.startsWith("modulos/")) {
|
||||
return `/${cleanPath}/`;
|
||||
}
|
||||
|
||||
// Fallback: normalize any other path preserving its segments.
|
||||
return `/${cleanPath}/`;
|
||||
}
|
||||
|
||||
function parseGitLogResponse(logData) {
|
||||
const rawLog = logData?.log;
|
||||
|
||||
if (typeof rawLog === "string") {
|
||||
return {
|
||||
entries: [],
|
||||
hasNoPreviousVersions: true,
|
||||
noPreviousVersionsMessage: rawLog
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(rawLog)) {
|
||||
return {
|
||||
entries: rawLog,
|
||||
hasNoPreviousVersions: false,
|
||||
noPreviousVersionsMessage: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
entries: [],
|
||||
hasNoPreviousVersions: false,
|
||||
noPreviousVersionsMessage: null
|
||||
};
|
||||
}
|
||||
|
||||
function getWsPayload(response) {
|
||||
if (!response || typeof response !== "object") {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Prefer direct WS payload shape: { log: [...], result: ... }
|
||||
if ("log" in response || "result" in response || "error" in response || "success" in response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Fallback for axios shape: { data: {...} }
|
||||
if (response.data && typeof response.data === "object") {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function extractCommitIdFromEntry(entry) {
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(entry)) {
|
||||
const id = entry[1];
|
||||
if (typeof id === "string" && id.trim()) {
|
||||
return id.trim();
|
||||
}
|
||||
|
||||
if (typeof id === "number") {
|
||||
return String(id);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof entry === "string") {
|
||||
return entry.trim();
|
||||
}
|
||||
|
||||
if (typeof entry !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directId = entry.id || entry.commitId || entry.hash || entry.sha || entry.commit;
|
||||
if (typeof directId === "string" && directId.trim()) {
|
||||
return directId.trim();
|
||||
}
|
||||
|
||||
const serializedEntry = JSON.stringify(entry);
|
||||
const hashMatch = serializedEntry.match(COMMIT_HASH_REGEX);
|
||||
return hashMatch ? hashMatch[0] : null;
|
||||
}
|
||||
|
||||
function formatLogEntries(logData, limit = MAX_LOG_LIMIT) {
|
||||
const { entries } = parseGitLogResponse(logData);
|
||||
const slicedEntries = entries.slice(0, limit);
|
||||
return slicedEntries.map((entry, index) => {
|
||||
const commitId = extractCommitIdFromEntry(entry);
|
||||
const date = Array.isArray(entry) ? entry[0] : (entry?.date || entry?.fecha || null);
|
||||
|
||||
if (Array.isArray(entry)) {
|
||||
return {
|
||||
index: index + 1,
|
||||
date,
|
||||
id: commitId,
|
||||
raw: entry
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof entry === "object" && entry !== null) {
|
||||
return {
|
||||
index: index + 1,
|
||||
date,
|
||||
id: commitId,
|
||||
...entry
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
index: index + 1,
|
||||
id: commitId,
|
||||
raw: entry
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getTargetText(path) {
|
||||
return path ? `este módulo ${path}` : "todo";
|
||||
}
|
||||
|
||||
export function registerRecoverGitTools(server) {
|
||||
server.tool(
|
||||
"list_git_log",
|
||||
`List the latest git history entries (up to 20) so the user can choose the rollback id.
|
||||
|
||||
Routing guidance:
|
||||
- If user says "haz rollback" or "recupera" without a commit id, use this tool first.
|
||||
- Show the returned commits and ask which id to use.
|
||||
- Do NOT choose an id automatically in that scenario.`,
|
||||
withAuthParams({
|
||||
path: z.string().optional().describe("Optional module path OR module id/name. Example path: /modulos/nameofthecustommodule_aeq9kl/. Example id/name: nameofthecustommodule_aeq9kl. Global hooks/header/footer/layout are in /modulos/layout/ (aliases header/footer/layout/hooks globales auto-map). If omitted, returns global git history."),
|
||||
limit: z.number().int().min(1).max(MAX_LOG_LIMIT).optional().describe("Maximum number of entries to return (default 20, max 20)."),
|
||||
}),
|
||||
withAuth(async ({ path, limit = MAX_LOG_LIMIT }, extra) => {
|
||||
try {
|
||||
const normalizedPath = path ? normalizePath(path) : '';
|
||||
if (normalizedPath) {
|
||||
lastModulePathBySession.set(extra.sessionId, normalizedPath);
|
||||
}
|
||||
|
||||
const client = await getApiClient(extra.sessionId);
|
||||
const logResponse = await client.post(
|
||||
"/cms/lib/viewer_functions.php",
|
||||
await getCommonParams(extra.sessionId, {
|
||||
action_ws: "getGitLog",
|
||||
...(normalizedPath ? { path: normalizedPath } : {})
|
||||
})
|
||||
);
|
||||
const logPayload = getWsPayload(logResponse);
|
||||
|
||||
const logError = handleApiResponse(logPayload, 'list_git_log:getGitLog');
|
||||
if (logError) return logError;
|
||||
|
||||
const parsedLog = parseGitLogResponse(logPayload);
|
||||
if (parsedLog.hasNoPreviousVersions) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: parsedLog.noPreviousVersionsMessage || "No hay versiones anteriores.",
|
||||
target: getTargetText(normalizedPath),
|
||||
path: normalizedPath,
|
||||
count: 0,
|
||||
commits: []
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const list = formatLogEntries(logPayload, limit);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: "Selecciona un id y llama a recover_git con ese id. Nota: el primer commit es la versión actual; el segundo es la versión anterior (último rollback sugerido).",
|
||||
target: getTargetText(normalizedPath),
|
||||
path: normalizedPath,
|
||||
count: list.length,
|
||||
commits: list
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'list_git_log', { path });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"recover_git",
|
||||
`Execute recoverGit using an explicit commit id chosen by the user.
|
||||
|
||||
Routing guidance:
|
||||
- Use this only when a specific commit id is already provided by the user.
|
||||
- If user did not provide id, call list_git_log first.
|
||||
|
||||
SAFETY: You must pass confirm=true to execute recovery.`,
|
||||
withAuthParams({
|
||||
id: z.string().describe("Commit id selected by the user from list_git_log."),
|
||||
path: z.string().optional().describe("Optional module path OR module id/name. Example path: /modulos/nameofthecustommodule_aeq9kl/. Example id/name: nameofthecustommodule_aeq9kl. Global hooks/header/footer/layout are in /modulos/layout/ (aliases header/footer/layout/hooks globales auto-map). If omitted, rollback applies to todo."),
|
||||
confirm: z.boolean().optional().describe("Set true to confirm execution. If false/omitted, tool will not execute recoverGit."),
|
||||
}),
|
||||
withAuth(async ({ id, path, confirm = false }, extra) => {
|
||||
try {
|
||||
const validationError = validateRequired({ id }, ['id'], 'recover_git');
|
||||
if (validationError) return validationError;
|
||||
|
||||
const normalizedPath = path ? normalizePath(path) : '';
|
||||
if (normalizedPath) {
|
||||
lastModulePathBySession.set(extra.sessionId, normalizedPath);
|
||||
}
|
||||
|
||||
const target = getTargetText(normalizedPath);
|
||||
|
||||
if (!confirm) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
requiresConfirmation: true,
|
||||
message: `Confirmación requerida para hacer rollback de ${target}. Ejecuta de nuevo con confirm=true.`,
|
||||
id,
|
||||
path: normalizedPath,
|
||||
target
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const client = await getApiClient(extra.sessionId);
|
||||
const response = await client.post(
|
||||
"/cms/lib/viewer_functions.php",
|
||||
await getCommonParams(extra.sessionId, {
|
||||
action_ws: "recoverGit",
|
||||
...(normalizedPath ? { path: normalizedPath } : {}),
|
||||
id
|
||||
})
|
||||
);
|
||||
const recoverPayload = getWsPayload(response);
|
||||
|
||||
const apiError = handleApiResponse(recoverPayload, 'recover_git');
|
||||
if (apiError) return apiError;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: `Rollback ejecutado sobre ${target}.`,
|
||||
id,
|
||||
path: normalizedPath,
|
||||
target,
|
||||
response: recoverPayload
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'recover_git');
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"recover_previous_git",
|
||||
`Rollback to the previous version (second commit in git log).
|
||||
|
||||
Routing guidance:
|
||||
- Use this when user says "haz rollback a la versión anterior" or "regresa a la versión anterior".
|
||||
- This tool proposes the previous version id (second entry in getGitLog), but the user must either select an id or confirm using that suggested id.
|
||||
|
||||
Rules:
|
||||
- If path is provided, use that module.
|
||||
- If path is omitted, use the last module path used in this session.
|
||||
- If there is no last module path, apply to todo (without path).
|
||||
- Always asks confirmation before executing rollback.`,
|
||||
withAuthParams({
|
||||
path: z.string().optional().describe("Optional module path OR module id/name. Example path: /modulos/nameofthecustommodule_aeq9kl/. Example id/name: nameofthecustommodule_aeq9kl. Global hooks/header/footer/layout are in /modulos/layout/ (aliases header/footer/layout/hooks globales auto-map). If omitted, uses the last module path from this session; if none, applies to todo."),
|
||||
selectedId: z.string().optional().describe("Commit id selected by the user. If omitted, you must set confirmLatest=true to use the suggested previous version id."),
|
||||
confirmLatest: z.boolean().optional().describe("Set true to confirm using the suggested 'último' id (second commit in log)."),
|
||||
confirm: z.boolean().optional().describe("Set true to confirm execution. If false/omitted, tool returns selected id and target."),
|
||||
}),
|
||||
withAuth(async ({ path, selectedId, confirmLatest = false, confirm = false }, extra) => {
|
||||
try {
|
||||
const normalizedPath = path
|
||||
? normalizePath(path)
|
||||
: (lastModulePathBySession.get(extra.sessionId) || null);
|
||||
|
||||
if (normalizedPath) {
|
||||
lastModulePathBySession.set(extra.sessionId, normalizedPath);
|
||||
}
|
||||
|
||||
const client = await getApiClient(extra.sessionId);
|
||||
const logResponse = await client.post(
|
||||
"/cms/lib/viewer_functions.php",
|
||||
await getCommonParams(extra.sessionId, {
|
||||
action_ws: "getGitLog",
|
||||
...(normalizedPath ? { path: normalizedPath } : {})
|
||||
})
|
||||
);
|
||||
const logPayload = getWsPayload(logResponse);
|
||||
|
||||
const logError = handleApiResponse(logPayload, 'recover_previous_git:getGitLog');
|
||||
if (logError) return logError;
|
||||
|
||||
const parsedLog = parseGitLogResponse(logPayload);
|
||||
if (parsedLog.hasNoPreviousVersions) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
message: parsedLog.noPreviousVersionsMessage || "No hay versiones anteriores.",
|
||||
target: getTargetText(normalizedPath),
|
||||
path: normalizedPath
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const entries = parsedLog.entries;
|
||||
if (entries.length < 2) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
message: "No hay suficiente historial para volver a la versión anterior. Se requieren al menos 2 commits.",
|
||||
target: getTargetText(normalizedPath),
|
||||
path: normalizedPath,
|
||||
entriesFound: entries.length
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const target = getTargetText(normalizedPath);
|
||||
const suggestedPreviousId = extractCommitIdFromEntry(entries[1]);
|
||||
|
||||
if (!suggestedPreviousId) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
requiresConfirmation: true,
|
||||
message: "No se pudo resolver el id sugerido de la versión anterior (segundo commit).",
|
||||
target,
|
||||
path: normalizedPath
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const finalId = confirmLatest ? suggestedPreviousId : selectedId;
|
||||
|
||||
if (!finalId) {
|
||||
const commits = formatLogEntries(logPayload, MAX_LOG_LIMIT);
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
requiresUserSelection: true,
|
||||
message: `Debes indicar selectedId o confirmar confirmLatest=true para usar el 'último' (segundo commit).`,
|
||||
note: "El primer commit es la versión actual; el segundo es la versión anterior.",
|
||||
target,
|
||||
path: normalizedPath,
|
||||
suggestedPreviousId,
|
||||
commits
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!confirm) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
requiresConfirmation: true,
|
||||
message: `Confirmación requerida para hacer rollback en ${target}.`,
|
||||
target,
|
||||
path: normalizedPath,
|
||||
suggestedPreviousId,
|
||||
finalId,
|
||||
usedSuggestedPrevious: confirmLatest
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const recoverResponse = await client.post(
|
||||
"/cms/lib/viewer_functions.php",
|
||||
await getCommonParams(extra.sessionId, {
|
||||
action_ws: "recoverGit",
|
||||
...(normalizedPath ? { path: normalizedPath } : {}),
|
||||
id: finalId
|
||||
})
|
||||
);
|
||||
const recoverPayload = getWsPayload(recoverResponse);
|
||||
|
||||
const recoverError = handleApiResponse(recoverPayload, 'recover_previous_git:recoverGit');
|
||||
if (recoverError) return recoverError;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: `Rollback ejecutado en ${target}.`,
|
||||
target,
|
||||
id: finalId,
|
||||
path: normalizedPath,
|
||||
suggestedPreviousId,
|
||||
usedSuggestedPrevious: confirmLatest,
|
||||
response: recoverPayload
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'recover_previous_git');
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
99
mcp-server/tools/tables/create.js
Normal file
99
mcp-server/tools/tables/create.js
Normal file
@@ -0,0 +1,99 @@
|
||||
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 { withAuthParams } from "../helpers/authSchema.js";
|
||||
|
||||
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.",
|
||||
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."),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ menuName, tableName, type, enlace, seo_metas = false, menuOrder }, extra) => {
|
||||
try {
|
||||
// Validate required parameters
|
||||
const validationError = validateRequired(
|
||||
{ menuName, tableName, type, enlace },
|
||||
['menuName', 'tableName', 'type', 'enlace'],
|
||||
'create_table'
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
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)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'create_table', { menuName, tableName, type });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
52
mcp-server/tools/tables/delete.js
Normal file
52
mcp-server/tools/tables/delete.js
Normal file
@@ -0,0 +1,52 @@
|
||||
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 { withAuthParams } from "../helpers/authSchema.js";
|
||||
|
||||
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.",
|
||||
withAuthParams({
|
||||
tableName: z.string().describe("Name of the table/module to delete (without 'cms_' prefix, e.g., 'equipo')"),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: true },
|
||||
withAuth(async ({ tableName }, 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)
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'delete_table', { tableName });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
207
mcp-server/tools/tables/fields.js
Normal file
207
mcp-server/tools/tables/fields.js
Normal file
@@ -0,0 +1,207 @@
|
||||
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 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
20
mcp-server/tools/tables/index.js
Normal file
20
mcp-server/tools/tables/index.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// 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';
|
||||
|
||||
export function registerTableTools(server) {
|
||||
// registerListTablesTool(server);
|
||||
// registerGetTableSchemaTool(server);
|
||||
// registerUpdateTableSchemaTool(server);
|
||||
// registerGetTableTemplatesTool(server);
|
||||
// registerCreateTableTool(server);
|
||||
// registerDeleteTableTool(server);
|
||||
// registerEditTableFieldTool(server);
|
||||
// registerDeleteTableFieldTool(server);
|
||||
}
|
||||
|
||||
|
||||
74
mcp-server/tools/tables/list.js
Normal file
74
mcp-server/tools/tables/list.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleToolError, handleApiResponse } from "../helpers/errorHandler.js";
|
||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.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"),
|
||||
}),
|
||||
{ readOnlyHint: true, destructiveHint: false },
|
||||
withAuth(async ({ withoutEnlace }, 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
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error getting tables: " + JSON.stringify(response.data) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 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) }],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, 'list_tables');
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
184
mcp-server/tools/tables/schema.js
Normal file
184
mcp-server/tools/tables/schema.js
Normal file
@@ -0,0 +1,184 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getApiClient, getSessionCredentials, getCommonParams } from "../../auth/index.js";
|
||||
import { normalizeSchemaForSave, mergeTableSchemas } from "../../utils/fieldHelpers.js";
|
||||
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
|
||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.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).",
|
||||
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)."),
|
||||
}),
|
||||
{ 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
|
||||
);
|
||||
|
||||
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) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Table '${tableName}' not found` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Minimal mode: return only field names, types, and key metadata
|
||||
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;
|
||||
}
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(minimalSchema, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(table, 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 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user