Initial commit

This commit is contained in:
Jordan
2026-04-01 23:16:45 +01:00
commit bc4199aed2
201 changed files with 25612 additions and 0 deletions

13
mcp-server/.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
node_modules
.git
.gitignore
.env
.DS_Store
*.log
# We might want monitor.html if it's static, but the code generates it if missing or reads it.
# Let's keep monitor.html if it exists in source, but ignore other junk.
npm-debug.log
Dockerfile
docker-compose.yml
DOCKER_README.md

17
mcp-server/.env.example Normal file
View File

@@ -0,0 +1,17 @@
# Acai MCP Server — Environment Variables
# Copy this file to .env and fill in your values.
# --- API Keys ---
NANO_BANANA_API_KEY=your_nano_banana_key
PIXABAY_API_KEY=your_pixabay_key
PEXELS_API_KEY=your_pexels_key
# --- Security ---
# Generate a strong random secret for JWT signing:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
MCP_JWT_SECRET=change_me_to_a_strong_random_value
# --- Optional: Default Acai credentials ---
# ACAI_TOKEN=your_token
# ACAI_WEBSITE=your_website
# ACAI_TOKEN_HASH=your_token_hash

28
mcp-server/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# Use Node.js 20 Alpine for a lightweight image
FROM node:20-alpine
# Set working directory
WORKDIR /app
# Copy package files first to leverage Docker cache
COPY package.json ./
# Install all dependencies (including devDependencies for nodemon in dev mode)
# Using npm install since package-lock.json is gitignored
RUN npm install
# Copy source code
COPY . .
# Expose the MCP SSE port and monitor UI port
EXPOSE 3000 4545
# Set environment variables (can be overridden in docker-compose for dev)
ENV NODE_ENV=development
# Disable the monitor by default in Docker unless explicitly enabled,
# but since we expose the port, let's assume the user might want it.
# However, for MCP stdio, we must ensure no stray logs go to stdout.
# The application code already handles this by using console.error for logs.
# Command to run the server
CMD ["node", "index.js"]

473
mcp-server/README.md Normal file
View File

@@ -0,0 +1,473 @@
# Acai Code MCP Server
Servidor MCP (Model Context Protocol) para Acai que permite a Claude y otros agentes IA acceder y manipular el código, módulos, tablas y registros de proyectos Acai.
## 📋 Contenido
- [Instalación Local](#instalación-local)
- [Docker (Producción)](#docker-producción)
- [Configuración de Clientes](#configuración-de-clientes)
- [Desarrollo](#desarrollo)
- [Estructura del Proyecto](#estructura-del-proyecto)
- [Troubleshooting](#troubleshooting)
---
## 🚀 Instalación Local
### Requisitos
- Node.js >= 16
- npm o yarn
### Pasos
1. **Instalar dependencias**
```bash
cd server
npm install
```
2. **Iniciar el servidor en modo desarrollo**
```bash
npm start
```
El servidor estará disponible en `http://localhost:3000/sse`
3. **Iniciar con watch mode** (recarga automática)
```bash
npm run dev
```
4. **Verificar que funciona**
```bash
curl http://localhost:3000/health
```
Deberías ver:
```json
{
"status": "ok",
"activeSessions": 0,
"mode": "sse"
}
```
---
## 🐳 Docker (Producción)
### Requisitos
- Docker instalado
- Docker Compose (opcional pero recomendado)
### Construcción de la imagen
```bash
# Desde la carpeta server/
docker build -t acai-mcp-server .
```
### Ejecución
**Opción 1: Docker Compose (Recomendado)**
```bash
docker-compose up -d
```
Esto inicia:
- Servidor MCP en puerto 3000
- Monitor UI en puerto 4545 (opcional)
- Auto-restart habilitado
Detener:
```bash
docker-compose down
```
**Opción 2: Docker directo**
```bash
docker run -d \
--name acai-mcp-server \
--restart unless-stopped \
-p 3000:3000 \
acai-mcp-server
```
### Ver logs
```bash
docker logs acai-mcp-server -f
```
### Parar/Reiniciar
```bash
docker stop acai-mcp-server
docker start acai-mcp-server
docker restart acai-mcp-server
```
---
## 🔧 Configuración de Clientes
### Claude Code (Recomendado)
Crea el archivo `.mcp.json` en la raíz de tu proyecto:
#### Opción A: Con X-User-Token (Simple)
```json
{
"mcpServers": {
"acai-code": {
"command": "npx",
"args": [
"mcp-remote",
"http://localhost:3000/sse",
"--header",
"X-User-Token: {TU_TOKEN_AQUI}"
]
}
}
}
```
#### Opción B: Con X-Acai-Token (Completo)
```json
{
"mcpServers": {
"acai-code": {
"command": "npx",
"args": [
"mcp-remote",
"http://localhost:3000/sse",
"--header",
"X-Acai-Token: {TU_TOKEN_AQUI}",
"--header",
"X-Acai-Token-Hash: {TU_TOKEN_HASH_AQUI}",
"--header",
"X-Acai-Website: {TU_DOMINIO_AQUI}"
]
}
}
}
```
### Obtener credenciales
1. Abre `https://cms.acaisuite.com/admin.php?debug=1`
2. Busca en la consola o en los Network headers:
- **X-User-Token**: Token único (contiene el dominio automáticamente)
- **X-Acai-Token**: Token de sesión
- **X-Acai-Token-Hash**: Hash de validación
- **X-Acai-Website**: Tu dominio
### Servidor remoto
Si el Docker está en otra máquina:
```json
{
"mcpServers": {
"acai-code": {
"command": "npx",
"args": [
"mcp-remote",
"http://192.168.1.100:3000/sse",
"--header",
"X-User-Token: {TU_TOKEN_AQUI}"
]
}
}
}
```
---
## 👨‍💻 Desarrollo
### Estructura
```
server/
├── tools/
│ ├── modules/ # Herramientas para módulos
│ ├── tables/ # Herramientas para tablas
│ ├── records/ # Herramientas para registros
│ ├── files/ # Herramientas para archivos
│ ├── media/ # Herramientas para media
│ ├── auth/ # Herramientas de autenticación
│ └── helpers/ # Utilidades compartidas
├── auth/
│ ├── apiClient.js # Cliente HTTP con auto-login
│ ├── credentials.js # Gestión de credenciales
│ └── index.js # Exportaciones
├── utils/
│ ├── moduleParser.js # Parser de componentes Acai
│ └── remoteParser.js # Parser remoto (appParser)
├── resources/ # Guías y documentación
├── server.js # Punto de entrada principal
├── httpServer.js # Servidor HTTP/SSE
└── package.json
```
### Agregar una nueva herramienta
1. **Crear archivo** `tools/category/toolname.js`:
```javascript
import { z } from "zod";
import { withAuth, getSessionCredentials, getApiClient } from "../../auth/index.js";
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
export function registerMyToolTool(server) {
server.tool(
"my_tool",
"Descripción de la herramienta",
{
param1: z.string().describe("Descripción del parámetro"),
},
withAuth(async ({ param1 }, extra) => {
try {
const credentials = getSessionCredentials(extra.sessionId);
const client = await getApiClient(extra.sessionId);
// Tu lógica aquí
const response = await client.post("/endpoint", {
action_ws: "mi_accion",
});
return {
content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }],
};
} catch (error) {
return handleToolError(error, 'my_tool', { param1 });
}
})
);
}
```
2. **Registrar en** `tools/category/index.js`:
```javascript
import { registerMyToolTool } from './toolname.js';
export function registerCategoryTools(server) {
// ... otras herramientas
registerMyToolTool(server);
}
```
3. **Probar**
```bash
npm run dev
```
### Scripts disponibles
```bash
npm start # Iniciar servidor
npm run dev # Desarrollo con watch
npm test # Ejecutar tests
npm run lint # Verificar linting
```
---
## 📚 Herramientas Disponibles
### Módulos (6)
- `list_modules` - Listar módulos
- `get_module` - Obtener contenido
- `save_module` - Crear/actualizar
- `check_module` - Validar sintaxis
- `check_module_usage` - Ver dónde se usa
- `delete_module` - Eliminar
### Tablas (6)
- `list_tables` - Listar tablas
- `get_table_schema` - Ver estructura
- `create_table` - Crear tabla
- `edit_table_field` - Editar campo
- `delete_table_field` - Eliminar campo
- `get_table_templates` - Obtener templates
### Registros (5)
- `list_records` - Listar registros
- `get_record` - Obtener uno
- `create_record` - Crear
- `update_record` - Actualizar
- `delete_record` - Eliminar
### Archivos (4)
- `list_files` - Listar archivos
- `read_file` - Leer contenido
- `write_file` - Crear/actualizar
- `delete_file` - Eliminar
### Media (3)
- `list_media` - Listar media
- `upload_media` - Subir archivo
- `delete_media` - Eliminar
### Auth (1)
- `get_session_info` - Info de sesión
---
## 🔐 Autenticación
### X-User-Token (Recomendado)
- Token único por usuario
- Incluye automáticamente el dominio
- Simplifica la configuración
- Trigger auto-login en primera petición
### X-Acai-Token + X-Acai-Token-Hash + X-Acai-Website
- Más flexible
- Permite cambiar dominio
- Requiere 3 headers
- Más control
### Auto-login
Si solo envías X-User-Token:
1. Se detecta en la conexión SSE
2. En la primera petición a una herramienta, se hace login
3. Las credenciales se cachean en la sesión
4. Las peticiones posteriores usan el token cacheado
---
## 🐛 Troubleshooting
### Puerto 3000 en uso
```bash
# Encontrar proceso en puerto 3000
lsof -i :3000
# Matar proceso
kill -9 <PID>
# O cambiar puerto
MCP_PORT=3001 npm start
```
### Error: "Token no válido" (403)
- Verifica que el token no ha expirado
- Obtén uno nuevo desde `https://cms.acaisuite.com/admin.php?debug=1`
- Revisa los logs: `docker logs acai-mcp-server -f`
### Error: "window is not defined"
- Asegúrate de pasar `listTables` a `parseComponents()`
- Revisa que `remoteParser.js` tiene las variables seteadas correctamente
### Conexión rechazada
```bash
# Verifica que está corriendo
curl http://localhost:3000/health
# Ver logs
npm run dev
# o
docker logs acai-mcp-server -f
```
### Tools no disponibles
- Verifica headers en `.mcp.json`
- Comprueba que el token es válido
- Revisa los logs del servidor
---
## 📝 Variables de entorno
```bash
MCP_PORT=3000 # Puerto del servidor MCP
MCP_MONITOR_PORT=4545 # Puerto del Monitor UI
MCP_MONITOR_DISABLED=0 # Desactivar Monitor UI
ACAI_TOKEN=... # Token por defecto (no recomendado)
ACAI_WEBSITE=... # Dominio por defecto
ACAI_TOKEN_HASH=... # Hash por defecto
```
---
## 🔄 Actualizar
### Versión local
```bash
git pull origin main
npm install
npm start
```
### Docker
```bash
# Reconstruir imagen
docker build -t acai-mcp-server .
# Reiniciar contenedor
docker restart acai-mcp-server
```
---
## 📖 Recursos
- **Guía Acai**: `resources/guia-programacion-acai.md`
- **Atributos**: `resources/guia-atributos-acai.md`
- **Twig Filters**: `resources/guia-twig-filters.md`
- **Builder Vars**: `resources/guia-builder-vars.md`
- **PHP Hooks**: `resources/guia-php-hooks.md`
---
## 🤝 Contribuir
1. Fork el proyecto
2. Crea una rama: `git checkout -b feature/nueva-herramienta`
3. Haz commit: `git commit -am 'Agregar nueva herramienta'`
4. Push: `git push origin feature/nueva-herramienta`
5. Abre un Pull Request
---
## 📞 Soporte
Para problemas o preguntas:
1. Revisa los logs: `docker logs acai-mcp-server -f`
2. Verifica la configuración en `.mcp.json`
3. Abre un issue en el repositorio
---
## 📄 Licencia
Igual que el proyecto principal de Acai.
---
**Última actualización**: Diciembre 2025

View File

@@ -0,0 +1,195 @@
{
"acai-search-files": {
"description": "Search for text patterns across project files using regex.\n\nSearches through module templates, hooks, sections, styles, and other project files.\nCalls: GET /api/files/search (PENDIENTE: endpoint por crear en el servidor Python).\n\nParameters:\n- query: Regex pattern to find (e.g., \"getVar\\\\(\" to find template variables)\n- include_pattern: Glob filter for files to include (e.g., \"cms/modules/**/*.tpl\")\n- exclude_pattern: Glob filter for files to exclude (e.g., \"**/node_modules/**\")\n- case_sensitive: Whether to match case (default: false)\n\nUseful for finding variable usage across templates, hook references, CSS class usage, etc.",
"parameters": {
"properties": {
"query": {
"example": "getVar\\(",
"type": "string"
},
"include_pattern": {
"example": "cms/modules/**/*.tpl",
"type": "string"
},
"exclude_pattern": {
"example": "**/node_modules/**",
"type": "string"
},
"case_sensitive": {
"example": "false",
"type": "boolean"
}
},
"required": ["query", "include_pattern"],
"type": "object"
}
},
"acai-write": {
"description": "Write content to a project file. Overwrites the existing file if it exists, or creates a new one.\nCalls: POST /api/files/write on the Python server.\n\nThe file path must be relative to the project root (e.g., cms/modules/mi_modulo/index-base.tpl).\n\nIMPORTANT: Prefer acai-line-replace for editing existing files. Use acai-write mainly for:\n- Creating new files (new modules, hooks, styles)\n- Complete file rewrites when most content changes\n\nWhen writing, use \"// ... keep existing code\" comments to preserve unchanged sections:\n- Any unchanged block over 5 lines MUST use this comment\n- The comment MUST contain the exact string \"... keep existing code\"\n- Example: \"// ... keep existing code (hook logic)\"\n\nIf creating multiple files (e.g., a new module with .tpl + .css + .js + hook.php), create all files in parallel.",
"parameters": {
"properties": {
"file_path": {
"example": "cms/modules/hero_banner/index-base.tpl",
"type": "string"
},
"content": {
"example": "<div class=\"hero-banner\">\n <h1>{{getVar('titulo')}}</h1>\n</div>",
"type": "string"
}
},
"required": ["file_path", "content"],
"type": "object"
}
},
"acai-line-replace": {
"description": "Line-based search and replace for editing existing project files.\nThis is the PREFERRED tool for modifying existing code — always use this instead of rewriting entire files with acai-write.\n\nLogic: Reads the file via GET /api/files/read, validates the search content at the specified line range, replaces it, and writes back via POST /api/files/write.\n\nProvide:\n1. file_path — Relative path from project root (e.g., cms/modules/hero/style.css)\n2. search — Content to find (use ... ellipsis for large sections)\n3. first_replaced_line — First line number (1-indexed)\n4. last_replaced_line — Last line number (1-indexed)\n5. replace — New content to replace with\n\nELLIPSIS USAGE (for sections > 6 lines):\n- Include first 2-3 lines of the section\n- Add \"...\" on its own line\n- Include last 2-3 lines\n- Focus on unique context for accurate matching\n\nWhen making multiple edits to the same file in parallel, always use the ORIGINAL line numbers from when you first read the file.\n\nAfter editing an index-base.tpl, you MUST call compile_module to sync changes with the CMS.",
"parameters": {
"properties": {
"file_path": {
"example": "cms/modules/hero_banner/index-base.tpl",
"type": "string"
},
"search": {
"example": "<div class=\"hero-banner\">\n <h1>{{getVar('titulo')}}</h1>\n...\n</div>",
"type": "string"
},
"first_replaced_line": {
"description": "First line number to replace (1-indexed)",
"example": "5",
"type": "number"
},
"last_replaced_line": {
"description": "Last line number to replace (1-indexed)",
"example": "12",
"type": "number"
},
"replace": {
"description": "New content to replace with (without line numbers)",
"example": "<section class=\"hero-banner hero-banner--fullwidth\">\n <h1>{{getVar('titulo')}}</h1>\n <p>{{getVar('subtitulo')}}</p>\n</section>",
"type": "string"
}
},
"required": ["file_path", "search", "first_replaced_line", "last_replaced_line", "replace"],
"type": "object"
}
},
"acai-view": {
"description": "Read the contents of a project file.\nCalls: GET /api/files/read on the Python server.\n\nThe file path must be relative to the project root. You can optionally specify line ranges for large files.\n\nGuidelines:\n- Do NOT use this if the file contents were already provided in context\n- By default reads the first 500 lines. Only use line ranges for large files\n- To read multiple files, invoke this tool multiple times in parallel\n\nCommon files to read:\n- cms/modules/{name}/index-base.tpl — Module HTML template\n- cms/modules/{name}/style.css — Module styles\n- cms/modules/{name}/script.js — Module JavaScript\n- cms/modules/{name}/hook.php — Module PHP hook\n- cms/hooks/{name}.php — Global hooks\n- cms/sections/custom-{name}/index-base.tpl — Custom sections",
"parameters": {
"properties": {
"file_path": {
"example": "cms/modules/hero_banner/index-base.tpl",
"type": "string"
},
"lines": {
"example": "1-100",
"type": "string"
}
},
"required": ["file_path"],
"type": "object"
}
},
"acai-delete": {
"description": "Delete a file from the project.\nCalls: POST /api/files/delete on the Python server.\n\nThe file path must be relative to the project root.\nUse with caution — this action is irreversible. Prefer acai-rename if you want to preserve the file.",
"parameters": {
"properties": {
"file_path": {
"example": "cms/modules/old_module/script.js",
"type": "string"
}
},
"required": ["file_path"],
"type": "object"
}
},
"acai-rename": {
"description": "Rename or move a file within the project.\nCalls: POST /api/files/rename (PENDIENTE: endpoint por crear en el servidor Python).\n\nUse this tool instead of creating a new file and deleting the old one.\nBoth paths must be relative to the project root.",
"parameters": {
"properties": {
"original_file_path": {
"example": "cms/modules/banner/style.css",
"type": "string"
},
"new_file_path": {
"example": "cms/modules/hero_banner/style.css",
"type": "string"
}
},
"required": ["original_file_path", "new_file_path"],
"type": "object"
}
},
"acai-copy": {
"description": "Copy a file or directory to a new location within the project.\nCalls: POST /api/files/copy (PENDIENTE: endpoint por crear en el servidor Python).\n\nUseful for duplicating modules or creating variations of existing templates.\nBoth paths must be relative to the project root.",
"parameters": {
"properties": {
"source_file_path": {
"example": "cms/modules/hero_banner/index-base.tpl",
"type": "string"
},
"destination_file_path": {
"example": "cms/modules/hero_banner_v2/index-base.tpl",
"type": "string"
}
},
"required": ["source_file_path", "destination_file_path"],
"type": "object"
}
},
"acai-read-php-logs": {
"description": "Read PHP and server logs from the project's Docker container.\nCalls: GET /api/logs/{container} on the Python server.\n\nReturns the last 200 lines of the container's log output. You can optionally provide a search query to filter relevant entries.\n\nIMPORTANT:\n- Logs are a snapshot from when the request was made — they do NOT update in real time\n- Do NOT call this more than once per task, as you will get the same results\n- Useful for debugging PHP errors in hooks, module rendering issues, or CMS API failures\n- Cannot verify fixes by re-reading logs — the snapshot won't change",
"parameters": {
"properties": {
"search": {
"description": "Optional text to filter log entries (e.g., 'Fatal error', 'Warning', module name)",
"example": "Fatal error",
"type": "string"
}
},
"required": [],
"type": "object"
}
},
"acai-fetch-website": {
"description": "Fetch a website URL and return its content as markdown, HTML, or screenshot.\nUseful for referencing external designs, documentation, or inspecting the live version of the project.\n\nReturns the content in the requested formats. Use markdown for text extraction, HTML for structure analysis, screenshot for visual reference.",
"parameters": {
"properties": {
"url": {
"example": "https://example.com",
"type": "string"
},
"formats": {
"description": "Comma-separated formats: 'markdown', 'html', 'screenshot'. Defaults to 'markdown'.",
"example": "markdown,screenshot",
"type": "string"
}
},
"required": ["url"],
"type": "object"
}
},
"acai-web-search": {
"description": "Search the web for information relevant to the development task.\n\nUse when you need:\n- Documentation for CSS properties, PHP functions, Twig syntax\n- Design inspiration or reference implementations\n- Troubleshooting specific errors or compatibility issues\n- Information about external APIs or services\n\nSearch tips:\n- Use site:domain.com to filter results (e.g., site:developer.mozilla.org CSS grid)\n- Use quotes for exact phrases (e.g., \"twig template\" getVar)\n- Exclude terms with minus (e.g., flexbox layout -bootstrap)",
"parameters": {
"properties": {
"query": {
"description": "Search query",
"example": "site:developer.mozilla.org CSS container queries",
"type": "string"
},
"numResults": {
"description": "Number of results to return (default: 5)",
"example": "5",
"type": "number"
},
"category": {
"description": "Optional category filter: 'news', 'github', 'pdf'",
"type": "string"
}
},
"required": ["query"],
"type": "object"
}
}
}

View File

@@ -0,0 +1,142 @@
import axios from "axios";
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
import { sessionApiClients, getSessionCredentials, setCredentials, findRoleByToken } from "./credentials.js";
import { assertSafeCmsTarget } from "../utils/cmsTargetSafety.js";
const DEFAULT_ROLE = 'developer';
/**
* Check if session is configured with valid credentials
*/
export const ensureConfigured = async (sessionId) => {
const creds = await getSessionCredentials(sessionId);
if (!creds.token || !creds.web_url || !creds.api_web_url) {
throw new McpError(
ErrorCode.InvalidRequest,
"Acai site not configured for safe local execution. Use the project MCP config/select_project flow so ACAI_API_WEB_URL points to the local environment."
);
}
try {
assertSafeCmsTarget(creds, "apiClient");
} catch (error) {
throw new McpError(
ErrorCode.InvalidRequest,
error.message
);
}
};
/**
* Rebuild API client for a session
*/
export const rebuildApiClient = async (sessionId) => {
const creds = await getSessionCredentials(sessionId);
if (!creds.token || !creds.web_url || !creds.api_web_url) {
return null;
}
assertSafeCmsTarget(creds, "apiClient");
const client = axios.create({
baseURL: creds.api_web_url,
headers: {
"Content-Type": "application/json",
"X-Acai-Token": creds.token,
...(creds.forge_host ? { Host: creds.forge_host } : {}),
},
});
// Request interceptor: always send latest token
client.interceptors.request.use((config) => {
if (creds.token) {
config.headers["X-Acai-Token"] = creds.token;
}
return config;
});
sessionApiClients.set(sessionId, client);
return client;
};
/**
* Get or create API client for a session
* @param {string} sessionId - The session ID
*/
export const getApiClient = async (sessionId) => {
const creds = await getSessionCredentials(sessionId);
console.error(`[API Client] getApiClient called for session ${sessionId}`);
console.error(`[API Client] Current creds: token=${!!creds.token}, web_url=${creds.web_url}`);
await ensureConfigured(sessionId);
let client = sessionApiClients.get(sessionId);
if (!client) {
console.error(`[API Client] No cached client, rebuilding...`);
client = await rebuildApiClient(sessionId);
}
if (!client) {
throw new McpError(
ErrorCode.InvalidRequest,
"Unable to create API client. Verify credentials and try again."
);
}
console.error(`[API Client] Returning client for session ${sessionId}`);
return client;
};
/**
* Set credentials and rebuild API client
* @param {Object} credentials - The credentials object
* @param {string} sessionId - The session ID
* @param {string} mcpSessionId - Optional MCP-Session-Id for persistence across SSE reconnections
*/
export const setCredentialsAndRebuild = async (credentials, sessionId, mcpSessionId = null) => {
await setCredentials(credentials, sessionId, mcpSessionId);
await rebuildApiClient(sessionId);
};
/**
* Wrapper for authenticated handlers
* Supports both session-based auth and inline credentials (stateless mode).
*
* If args contains acaiToken + acaiWebsite, these are used directly,
* allowing Claude to send credentials with each request.
*/
export const withAuth = (handler) => {
return async (args, extra) => {
const sessionId = extra?.sessionId || "_default";
console.error(`[withAuth] Called with sessionId: ${sessionId}`);
// Check for inline credentials (stateless mode)
const inlineCredentials = {
acaiToken: args.acaiToken,
acaiWebsite: args.acaiWebsite,
acaiTokenHash: args.acaiTokenHash
};
const hasInlineCredentials = inlineCredentials.acaiToken && inlineCredentials.acaiWebsite;
if (hasInlineCredentials) {
// Lookup role by token before storing credentials
const role = findRoleByToken(inlineCredentials.acaiToken) || DEFAULT_ROLE;
console.error(`[withAuth] Using INLINE credentials: website=${inlineCredentials.acaiWebsite}, role=${role}`);
// Temporarily store inline credentials in session for this request
await setCredentials({
token: inlineCredentials.acaiToken,
website: inlineCredentials.acaiWebsite,
web_url: `https://${inlineCredentials.acaiWebsite}`,
api_web_url: null,
forge_host: null,
tokenHash: inlineCredentials.acaiTokenHash || null,
profileName: 'inline',
role: role
}, sessionId);
}
console.error(`[withAuth] Getting API client for session ${sessionId}...`);
await getApiClient(sessionId);
console.error(`[withAuth] API client ready, calling handler...`);
return handler(args, { ...extra, sessionId, inlineCredentials: hasInlineCredentials ? inlineCredentials : null });
};
};

View File

@@ -0,0 +1,251 @@
/**
* Session-based credentials management
*
* IMPORTANT: Each session is completely isolated.
* Credentials are stored ONLY by sessionId, never shared between sessions.
* This prevents credential leakage when multiple Claude tabs connect to different websites.
*
* AUTH TOKEN PERSISTENCE:
* Claude MCP frequently reconnects SSE, creating new sessionIds each time.
* To maintain credentials across reconnections, we also index by authToken
* (the SimpleAuth header that Claude sends with each request).
*/
const DEFAULT_ROLE = 'developer';
// Session-based credentials storage (ephemeral, per-session)
export const sessionCredentials = new Map();
export const sessionApiClients = new Map();
export const sessionUserTokens = new Map();
// Map sessionId -> McpServer instance (for role-based tool filtering)
export const sessionServers = new Map();
// MCP-Session-Id -> credentials mapping for persistence across SSE reconnections
// This is the standard MCP mechanism for session persistence
// Key: MCP-Session-Id (UUID), Value: { credentials, lastAccess }
export const mcpSessionCredentials = new Map();
// TTL for MCP session credentials (30 minutes - longer than authToken since it's per-conversation)
const MCP_SESSION_TTL_MS = 30 * 60 * 1000;
/**
* Clean up expired MCP session credentials
*/
const cleanupExpiredMcpSessions = () => {
const now = Date.now();
for (const [mcpSessionId, data] of mcpSessionCredentials.entries()) {
if (now - data.lastAccess > MCP_SESSION_TTL_MS) {
console.error(`[Credentials] Cleaning up expired MCP-Session-Id ${mcpSessionId.substring(0, 8)}... (age: ${Math.round((now - data.lastAccess) / 1000)}s)`);
mcpSessionCredentials.delete(mcpSessionId);
}
}
};
// Run cleanup every 5 minutes
setInterval(cleanupExpiredMcpSessions, 5 * 60 * 1000);
/**
* Get credentials by MCP-Session-Id
*/
export const getMcpSessionCredentials = (mcpSessionId) => {
const data = mcpSessionCredentials.get(mcpSessionId);
if (data) {
data.lastAccess = Date.now();
console.error(`[Credentials] getMcpSessionCredentials(${mcpSessionId.substring(0, 8)}...) - FOUND: website=${data.credentials.website}`);
return data.credentials;
}
console.error(`[Credentials] getMcpSessionCredentials(${mcpSessionId.substring(0, 8)}...) - NOT FOUND`);
return null;
};
/**
* Set credentials by MCP-Session-Id
*/
export const setMcpSessionCredentials = (mcpSessionId, credentials) => {
mcpSessionCredentials.set(mcpSessionId, {
credentials,
lastAccess: Date.now()
});
console.error(`[Credentials] setMcpSessionCredentials(${mcpSessionId.substring(0, 8)}...) - website=${credentials.website} (total: ${mcpSessionCredentials.size})`);
};
/**
* Find role by token lookup in session storage
* @param {string} token - The acaiToken to lookup
* @returns {string|null} The role associated with this token, or null if not found
*/
export const findRoleByToken = (token) => {
if (!token) return null;
// Search in sessionCredentials Map
for (const [sessionId, creds] of sessionCredentials.entries()) {
if (creds.token === token && creds.role) {
console.error(`[Credentials] findRoleByToken - FOUND role=${creds.role} for token in session ${sessionId}`);
return creds.role;
}
}
// Search in mcpSessionCredentials Map
for (const [mcpSessionId, data] of mcpSessionCredentials.entries()) {
if (data.credentials.token === token && data.credentials.role) {
console.error(`[Credentials] findRoleByToken - FOUND role=${data.credentials.role} for token in MCP session ${mcpSessionId.substring(0, 8)}...`);
return data.credentials.role;
}
}
console.error(`[Credentials] findRoleByToken - NOT FOUND for token`);
return null;
};
/**
* Get credentials for a specific session
* Supports inline credentials that take priority over session credentials.
* This allows Claude to send credentials with each request (stateless mode).
*
* @param {string} sessionId - The session ID
* @param {Object} inlineCredentials - Optional inline credentials from tool params
* @param {string} inlineCredentials.acaiToken - Token passed directly in tool call
* @param {string} inlineCredentials.acaiWebsite - Website passed directly in tool call
* @param {string} inlineCredentials.acaiTokenHash - Token hash passed directly in tool call
*/
export const getSessionCredentials = async (sessionId, inlineCredentials = null) => {
// Priority 1: Inline credentials (stateless mode - Claude sends token with each request)
if (inlineCredentials?.acaiToken && inlineCredentials?.acaiWebsite) {
// Lookup role by token in session storage
const role = findRoleByToken(inlineCredentials.acaiToken);
console.error(`[Credentials] getSessionCredentials(${sessionId}) - USING INLINE: website=${inlineCredentials.acaiWebsite}, role=${role || 'not found'}`);
return {
token: inlineCredentials.acaiToken,
website: inlineCredentials.acaiWebsite,
web_url: `https://${inlineCredentials.acaiWebsite}`,
api_web_url: null,
forge_host: null,
tokenHash: inlineCredentials.acaiTokenHash || null,
profileName: 'inline',
role: role || DEFAULT_ROLE // Merge role from session storage, fallback to default
};
}
// Priority 2: Session credentials
const sessionCreds = sessionCredentials.get(sessionId);
if (sessionCreds) {
console.error(`[Credentials] getSessionCredentials(${sessionId}) - FOUND: website=${sessionCreds.website}, hasToken=${!!sessionCreds.token}`);
return sessionCreds;
}
// Priority 3: Fallback to environment variables (for backwards compatibility)
console.error(`[Credentials] getSessionCredentials(${sessionId}) - NOT FOUND, using env fallback`);
console.error(`[Credentials] Active sessions: [${Array.from(sessionCredentials.keys()).join(', ')}]`);
console.error(`[Credentials] Active MCP sessions: ${mcpSessionCredentials.size}`);
const envWebsite = process.env.ACAI_WEBSITE || null;
return {
token: process.env.ACAI_TOKEN || null,
website: envWebsite,
web_url: process.env.ACAI_WEB_URL || (envWebsite ? `https://${envWebsite}` : null),
api_web_url: process.env.ACAI_API_WEB_URL || null,
forge_host: process.env.ACAI_FORGE_HOST || null,
tokenHash: process.env.ACAI_TOKEN_HASH || null,
profileName: 'default',
role: 'developer', // Env fallback = local dev, full access
};
};
/**
* Get X-User-Token for a specific session (for fallback login)
*/
export const getSessionUserToken = (sessionId) => {
return sessionUserTokens.get(sessionId) || null;
};
/**
* Set X-User-Token for a specific session
*/
export const setSessionUserToken = (userToken, sessionId) => {
if (userToken) {
sessionUserTokens.set(sessionId, userToken);
}
};
/**
* Set credentials for a specific session
* Credentials are stored by sessionId AND by mcpSessionId (for SSE reconnection persistence).
* @param {Object} credentials - The credentials object
* @param {string} sessionId - The session ID (SSE transport session)
* @param {string} mcpSessionId - Optional MCP-Session-Id for persistence across SSE reconnections
*/
export const setCredentials = async ({ website, web_url, api_web_url, forge_host, token, tokenHash, profileName, role }, sessionId, mcpSessionId = null) => {
console.error(`[Credentials] setCredentials(${sessionId}) - website=${website}, web_url=${web_url}, api_web_url=${api_web_url}, forge_host=${forge_host || ""}, hasToken=${!!token}, hasTokenHash=${!!tokenHash}, profile=${profileName || "manual"}, role=${role || 'default'}, hasMcpSessionId=${!!mcpSessionId}`);
const creds = {
website,
web_url: web_url || (website ? `https://${website}` : null),
api_web_url: api_web_url || null,
forge_host: forge_host || null,
token,
tokenHash,
profileName: profileName || "manual",
role: role || DEFAULT_ROLE,
};
// Store by sessionId
sessionCredentials.set(sessionId, creds);
// Also store by MCP-Session-Id for persistence across SSE reconnections
if (mcpSessionId) {
setMcpSessionCredentials(mcpSessionId, creds);
}
// Verify it was set
const verify = sessionCredentials.get(sessionId);
console.error(`[Credentials] Verification: exists=${!!verify}, website=${verify?.website}`);
};
/**
* Clear credentials for a session
* NOTE: We only clear the sessionId mappings, NOT the authToken mappings.
* This allows credentials to persist across SSE reconnections.
* AuthToken credentials will be cleaned up by the TTL cleanup routine.
*/
export const clearSessionCredentials = (sessionId) => {
console.error(`[Credentials] Clearing session ${sessionId} (authToken credentials preserved for reconnection)`);
sessionCredentials.delete(sessionId);
sessionApiClients.delete(sessionId);
sessionUserTokens.delete(sessionId);
sessionServers.delete(sessionId);
// NOTE: We intentionally do NOT clear authTokenCredentials here
// to allow credentials to persist across SSE reconnections
};
/**
* Get common params for API requests
* @param {string} sessionId - The session ID
* @param {Object} extraParams - Extra parameters to include
*/
export const getCommonParams = async (sessionId, extraParams = {}) => {
const creds = await getSessionCredentials(sessionId);
const params = {
token: creds.token,
...extraParams
};
if (creds.tokenHash) {
params.tokenHash = creds.tokenHash;
}
return params;
};
/**
* Extract MCP-Session-Id from request headers
* @param {Object} extra - The extra object passed to tool handlers
* @returns {string|null} The MCP-Session-Id or null
*/
export const extractMcpSessionId = (extra) => {
const headers = extra?.requestInfo?.headers;
if (!headers) return null;
return headers['mcp-session-id'] || null;
};

26
mcp-server/auth/index.js Normal file
View File

@@ -0,0 +1,26 @@
export {
sessionCredentials,
sessionApiClients,
sessionServers,
mcpSessionCredentials,
getSessionCredentials,
setCredentials,
clearSessionCredentials,
getCommonParams,
getSessionUserToken,
setSessionUserToken,
getMcpSessionCredentials,
setMcpSessionCredentials,
extractMcpSessionId
} from './credentials.js';
export {
ensureConfigured,
rebuildApiClient,
getApiClient,
setCredentialsAndRebuild,
withAuth
} from './apiClient.js';
export { fetchProjectInfo, fetchProjectsList } from './localClient.js';

View File

@@ -0,0 +1,14 @@
import axios from "axios";
import { LOCAL_SERVER_URL } from "../config/index.js";
export async function fetchProjectInfo(projectName) {
const response = await axios.get(`${LOCAL_SERVER_URL}/api/mcp/project-info`, {
params: { project: projectName }
});
return response.data; // { success, web_url, token, tokenHash, domain, project_dir }
}
export async function fetchProjectsList() {
const response = await axios.get(`${LOCAL_SERVER_URL}/api/mcp/projects`);
return response.data; // { success, projects: [...] }
}

View File

@@ -0,0 +1,207 @@
/**
* Redis Client for Session Persistence
*
* Provides persistent storage for user credentials across server restarts.
* Falls back to in-memory Map if Redis is unavailable.
*/
import { createClient } from 'redis';
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
const USER_CACHE_TTL = 30 * 60; // 30 minutes in seconds
let redisClient = null;
let redisAvailable = false;
// Fallback in-memory cache (used if Redis is unavailable)
const memoryCache = new Map();
/**
* Initialize Redis client
*/
export async function initRedis() {
try {
console.error('[Redis] Connecting to Redis at', REDIS_URL);
redisClient = createClient({
url: REDIS_URL,
socket: {
connectTimeout: 5000,
reconnectStrategy: (retries) => {
if (retries > 3) {
console.error('[Redis] Max reconnection attempts reached, falling back to memory cache');
redisAvailable = false;
return false; // Stop reconnecting
}
return Math.min(retries * 100, 3000);
}
}
});
redisClient.on('error', (err) => {
console.error('[Redis] Error:', err.message);
redisAvailable = false;
});
redisClient.on('connect', () => {
console.error('[Redis] Connected successfully');
redisAvailable = true;
});
redisClient.on('reconnecting', () => {
console.error('[Redis] Reconnecting...');
});
await redisClient.connect();
redisAvailable = true;
console.error('[Redis] Ready to use');
} catch (error) {
console.error('[Redis] Failed to initialize:', error.message);
console.error('[Redis] Falling back to in-memory cache');
redisAvailable = false;
}
}
/**
* Set user credentials in cache (Redis or memory)
*/
export async function setUserCredentials(userIdentifier, credentials) {
if (!userIdentifier) {
console.error('[Redis] Cannot set credentials: no userIdentifier');
return false;
}
const key = `user:creds:${userIdentifier}`;
const value = JSON.stringify({
...credentials,
lastUsed: Date.now()
});
if (redisAvailable && redisClient) {
try {
await redisClient.setEx(key, USER_CACHE_TTL, value);
console.error(`[Redis] Saved credentials for user ${userIdentifier} (TTL: ${USER_CACHE_TTL}s)`);
return true;
} catch (error) {
console.error('[Redis] Error saving to Redis:', error.message);
console.error('[Redis] Falling back to memory cache for this operation');
redisAvailable = false;
}
}
// Fallback to memory cache
memoryCache.set(key, {
value,
expiresAt: Date.now() + (USER_CACHE_TTL * 1000)
});
console.error(`[Redis] Saved credentials for user ${userIdentifier} to memory cache`);
return true;
}
/**
* Get user credentials from cache (Redis or memory)
*/
export async function getUserCredentials(userIdentifier) {
if (!userIdentifier) {
return null;
}
const key = `user:creds:${userIdentifier}`;
if (redisAvailable && redisClient) {
try {
const value = await redisClient.get(key);
if (value) {
console.error(`[Redis] Retrieved credentials for user ${userIdentifier} from Redis`);
const creds = JSON.parse(value);
// Update lastUsed timestamp
await setUserCredentials(userIdentifier, {
website: creds.website,
token: creds.token,
tokenHash: creds.tokenHash,
profileName: creds.profileName
});
return creds;
}
} catch (error) {
console.error('[Redis] Error reading from Redis:', error.message);
console.error('[Redis] Falling back to memory cache');
redisAvailable = false;
}
}
// Fallback to memory cache
const cached = memoryCache.get(key);
if (cached) {
if (Date.now() < cached.expiresAt) {
console.error(`[Redis] Retrieved credentials for user ${userIdentifier} from memory cache`);
const creds = JSON.parse(cached.value);
// Update expiration
memoryCache.set(key, {
value: cached.value,
expiresAt: Date.now() + (USER_CACHE_TTL * 1000)
});
return creds;
} else {
console.error(`[Redis] Memory cache expired for user ${userIdentifier}`);
memoryCache.delete(key);
}
}
return null;
}
/**
* Delete user credentials from cache
*/
export async function deleteUserCredentials(userIdentifier) {
if (!userIdentifier) {
return;
}
const key = `user:creds:${userIdentifier}`;
if (redisAvailable && redisClient) {
try {
await redisClient.del(key);
console.error(`[Redis] Deleted credentials for user ${userIdentifier} from Redis`);
} catch (error) {
console.error('[Redis] Error deleting from Redis:', error.message);
}
}
// Also delete from memory cache
memoryCache.delete(key);
console.error(`[Redis] Deleted credentials for user ${userIdentifier} from memory cache`);
}
/**
* Get Redis health status
*/
export function getRedisStatus() {
return {
available: redisAvailable,
connected: redisClient?.isOpen || false,
url: REDIS_URL,
fallbackCacheSize: memoryCache.size
};
}
/**
* Close Redis connection (for graceful shutdown)
*/
export async function closeRedis() {
if (redisClient) {
try {
await redisClient.quit();
console.error('[Redis] Connection closed');
} catch (error) {
console.error('[Redis] Error closing connection:', error.message);
}
}
}

44
mcp-server/cluster.js Normal file
View File

@@ -0,0 +1,44 @@
/**
* Acai Code MCP Server - Cluster Mode
*
* Ejecuta múltiples workers para aprovechar todos los CPUs.
* Cada worker maneja sus propias conexiones SSE.
*/
import cluster from 'node:cluster';
import os from 'node:os';
const numCPUs = os.cpus().length;
// Usar máximo 4 workers o el número de CPUs, lo que sea menor
const numWorkers = Math.min(numCPUs, 4);
if (cluster.isPrimary) {
console.log(`[Cluster] Master ${process.pid} iniciando`);
console.log(`[Cluster] CPUs disponibles: ${numCPUs}`);
console.log(`[Cluster] Spawneando ${numWorkers} workers...`);
// Spawnear workers
for (let i = 0; i < numWorkers; i++) {
cluster.fork();
}
// Reiniciar worker si muere
cluster.on('exit', (worker, code, signal) => {
console.error(`[Cluster] Worker ${worker.process.pid} murió (${signal || code}). Reiniciando...`);
cluster.fork();
});
// Log cuando un worker está listo
cluster.on('online', (worker) => {
console.log(`[Cluster] Worker ${worker.process.pid} online`);
});
} else {
// Worker: ejecutar el servidor MCP
import('./index.js').then(() => {
console.log(`[Worker ${process.pid}] MCP Server iniciado`);
}).catch((err) => {
console.error(`[Worker ${process.pid}] Error:`, err);
process.exit(1);
});
}

102
mcp-server/config/index.js Normal file
View File

@@ -0,0 +1,102 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load .env from server directory
dotenv.config({ path: path.join(__dirname, '..', '.env') });
export const CONFIG_FILE_PATH =
process.env.ACAI_CONFIG_PATH ||
path.join(__dirname, "..", "..", "mcp-config.json");
export const MCP_PORT = Number(process.env.MCP_PORT || 3000);
export const MONITOR_PORT = Number(process.env.MCP_MONITOR_PORT || 4545);
export const MONITOR_DISABLED =
String(process.env.MCP_MONITOR_DISABLED || "").toLowerCase() === "1" ||
String(process.env.MCP_MONITOR_DISABLED || "").toLowerCase() === "true";
export const JWT_SECRET =
process.env.MCP_JWT_SECRET ||
process.env.JWT_SECRET ||
"change_me_in_env";
if (JWT_SECRET === "change_me_in_env") {
console.warn("[config] WARNING: JWT_SECRET is using the default value. Set MCP_JWT_SECRET or JWT_SECRET in your .env file for production.");
}
export const LOCAL_SERVER_URL = process.env.LOCAL_SERVER_URL || 'http://localhost:29871';
// Auth headers para llamadas internas al server Python
export function getLocalServerHeaders() {
const headers = { "Content-Type": "application/json" };
// En Forge, usar X-Acai-Token para auth interna
const token = process.env.ACAI_TOKEN || "";
const website = process.env.ACAI_WEBSITE || "";
if (token && website) {
headers["X-Acai-Token"] = token;
headers["X-Acai-Website"] = website;
}
return headers;
}
export const SAAS_URL = "https://ws.cocosolution.com/api/schemas/";
export const CMS_URL = "https://acai.cms.cocosolution.com";
const selectProfile = (config) => {
if (!config || typeof config !== "object") {
return null;
}
if (config.token && config.website) {
return { ...config, profileName: config.profileName || "default" };
}
const profiles = config.profiles || {};
const profileKey =
process.env.ACAI_PROFILE ||
config.defaultProfile ||
Object.keys(profiles)[0];
if (profileKey && profiles[profileKey]) {
return {
...profiles[profileKey],
profileName: profileKey,
};
}
return null;
};
export const loadLocalConfigProfile = () => {
if (!fs.existsSync(CONFIG_FILE_PATH)) {
return null;
}
try {
const raw = fs.readFileSync(CONFIG_FILE_PATH, "utf-8");
const parsed = JSON.parse(raw);
return selectProfile(parsed);
} catch (error) {
console.error(`[config] Could not read ${CONFIG_FILE_PATH}: ${error.message}`);
return null;
}
};
export const applyProfileToEnv = (profile) => {
if (!profile) {
return;
}
if (!process.env.ACAI_TOKEN && profile.token) {
process.env.ACAI_TOKEN = profile.token;
}
if (!process.env.ACAI_TOKEN_HASH && profile.tokenHash) {
process.env.ACAI_TOKEN_HASH = profile.tokenHash;
}
if (!process.env.ACAI_WEBSITE && profile.website) {
process.env.ACAI_WEBSITE = profile.website;
}
console.error(`[config] Loaded Acai profile '${profile.profileName}' from ${CONFIG_FILE_PATH}`);
};

263
mcp-server/debug_client.py Normal file
View File

@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""
Cliente debug para el MCP server via stdio.
Arranca el servidor como subprocess y te deja mandarle JSON-RPC interactivamente.
Uso:
python3 debug_client.py [proyecto_dir]
Ejemplo:
python3 debug_client.py /Users/jordandiaz/webs-locales/keepsailing.es
Comandos especiales:
init - Manda initialize + initialized automaticamente
tools - Lista las tools disponibles
call - Modo interactivo para llamar una tool
prompts - Lista los prompts
resources - Lista los resources
quit/exit - Salir
"""
import subprocess
import json
import sys
import os
import threading
import time
# Colores para la terminal
GREEN = "\033[92m"
CYAN = "\033[96m"
YELLOW = "\033[93m"
RED = "\033[91m"
DIM = "\033[2m"
RESET = "\033[0m"
class McpDebugClient:
def __init__(self, project_dir=""):
self.project_dir = project_dir
self.msg_id = 0
self.proc = None
self.responses = {}
self._reader_thread = None
def start(self):
"""Arranca el proceso MCP stdio."""
env = os.environ.copy()
if self.project_dir:
env["ACAI_PROJECT_DIR"] = self.project_dir
mcp_file = os.path.join(self.project_dir, ".mcp.json")
if os.path.exists(mcp_file):
try:
with open(mcp_file) as f:
data = json.load(f)
server = data.get("mcpServers", {}).get("acai-code", {})
server_env = server.get("env", {})
for key in (
"ACAI_WEBSITE",
"ACAI_WEB_URL",
"ACAI_API_WEB_URL",
"ACAI_FORGE_HOST",
"ACAI_TOKEN",
"ACAI_TOKEN_HASH",
"ACAI_PROJECT_DIR",
):
if server_env.get(key):
env.setdefault(key, server_env[key])
print(
f"{GREEN}Leido .mcp.json:{RESET} "
f"website={env.get('ACAI_WEBSITE')}, "
f"web_url={env.get('ACAI_WEB_URL')}, "
f"api_web_url={env.get('ACAI_API_WEB_URL')}"
)
except Exception as e:
print(f"{YELLOW}No se pudo leer .mcp.json: {e}{RESET}")
# Fallback a .acai para sacar website, token y una URL basica si no hay .mcp.json
acai_file = os.path.join(self.project_dir, ".acai")
if os.path.exists(acai_file):
try:
with open(acai_file) as f:
data = json.load(f)
website = data.get("website") or data.get("domain") or ""
web_url = data.get("web_url") or data.get("webUrl") or ""
if not web_url and website:
scheme = "https" if data.get("ssl", True) else "http"
web_url = f"{scheme}://{website}"
env.setdefault("ACAI_WEBSITE", website)
env.setdefault("ACAI_WEB_URL", web_url)
if data.get("token"):
env.setdefault("ACAI_TOKEN", data["token"])
if data.get("tokenHash"):
env.setdefault("ACAI_TOKEN_HASH", data["tokenHash"])
print(f"{GREEN}Leido .acai:{RESET} website={env.get('ACAI_WEBSITE')}, web_url={env.get('ACAI_WEB_URL')}")
except Exception as e:
print(f"{YELLOW}No se pudo leer .acai: {e}{RESET}")
script_dir = os.path.dirname(os.path.abspath(__file__))
stdio_path = os.path.join(script_dir, "stdio.js")
print(f"{DIM}Arrancando: node {stdio_path}{RESET}")
self.proc = subprocess.Popen(
["node", stdio_path],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
cwd=script_dir,
)
# Hilo para leer stderr (logs del server)
self._reader_thread = threading.Thread(target=self._read_stderr, daemon=True)
self._reader_thread.start()
# Dar tiempo a que arranque
time.sleep(1)
def _read_stderr(self):
"""Lee stderr del server y lo muestra."""
for line in self.proc.stderr:
text = line.decode("utf-8", errors="replace").rstrip()
if text:
print(f"{DIM}[server] {text}{RESET}")
def send(self, obj):
"""Envia un mensaje JSON-RPC al server."""
raw = json.dumps(obj)
msg = raw + "\n"
print(f"\n{CYAN}>>> Enviando:{RESET}")
print(json.dumps(obj, indent=2, ensure_ascii=False))
self.proc.stdin.write(msg.encode("utf-8"))
self.proc.stdin.flush()
def recv(self, timeout=10):
"""Lee una respuesta del server."""
self.proc.stdout.flush()
line = self.proc.stdout.readline().decode("utf-8", errors="replace")
if not line:
return None
obj = json.loads(line)
print(f"\n{GREEN}<<< Respuesta:{RESET}")
print(json.dumps(obj, indent=2, ensure_ascii=False))
return obj
def request(self, method, params=None):
"""Envia un request y espera la respuesta."""
self.msg_id += 1
msg = {"jsonrpc": "2.0", "id": self.msg_id, "method": method}
if params is not None:
msg["params"] = params
self.send(msg)
return self.recv()
def notify(self, method, params=None):
"""Envia una notificacion (sin id, no espera respuesta)."""
msg = {"jsonrpc": "2.0", "method": method}
if params is not None:
msg["params"] = params
self.send(msg)
def initialize(self):
"""Handshake completo: initialize + initialized."""
print(f"\n{YELLOW}=== Inicializando ==={RESET}")
resp = self.request("initialize", {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "debug_client", "version": "1.0.0"}
})
self.notify("notifications/initialized")
print(f"\n{GREEN}Inicializado OK{RESET}")
if resp and "result" in resp:
caps = resp["result"].get("capabilities", {})
if caps.get("tools"):
print(f" Tools: disponibles")
if caps.get("prompts"):
print(f" Prompts: disponibles")
if caps.get("resources"):
print(f" Resources: disponibles")
return resp
def list_tools(self):
"""Lista las tools."""
resp = self.request("tools/list")
if resp and "result" in resp:
tools = resp["result"].get("tools", [])
print(f"\n{YELLOW}=== {len(tools)} tools ==={RESET}")
for t in tools:
desc = t.get("description", "")[:60]
print(f" {GREEN}{t['name']}{RESET} - {desc}")
return resp
def call_tool(self):
"""Modo interactivo para llamar una tool."""
name = input(f"{CYAN}Nombre de la tool: {RESET}").strip()
if not name:
return
print(f"Argumentos como JSON (enter para {{}}):")
args_str = input(f"{CYAN}> {RESET}").strip()
args = json.loads(args_str) if args_str else {}
return self.request("tools/call", {"name": name, "arguments": args})
def stop(self):
if self.proc:
self.proc.terminate()
self.proc.wait()
def main():
project_dir = sys.argv[1] if len(sys.argv) > 1 else ""
print(f"{YELLOW}╔══════════════════════════════════╗{RESET}")
print(f"{YELLOW}║ MCP Debug Client (stdio) ║{RESET}")
print(f"{YELLOW}╚══════════════════════════════════╝{RESET}")
print()
print("Comandos: init, tools, call, prompts, resources, json, quit")
print()
client = McpDebugClient(project_dir)
client.start()
try:
while True:
try:
cmd = input(f"\n{CYAN}mcp> {RESET}").strip().lower()
except EOFError:
break
if not cmd:
continue
elif cmd in ("quit", "exit", "q"):
break
elif cmd == "init":
client.initialize()
elif cmd == "tools":
client.list_tools()
elif cmd == "call":
client.call_tool()
elif cmd == "prompts":
client.request("prompts/list")
elif cmd == "resources":
client.request("resources/list")
elif cmd == "json":
print("Pega el JSON-RPC completo:")
raw = input(f"{CYAN}> {RESET}").strip()
try:
obj = json.loads(raw)
if "id" not in obj:
client.notify(obj.get("method"), obj.get("params"))
else:
client.send(obj)
client.recv()
except json.JSONDecodeError as e:
print(f"{RED}JSON invalido: {e}{RESET}")
else:
print(f"{YELLOW}Comando no reconocido. Usa: init, tools, call, prompts, resources, json, quit{RESET}")
except KeyboardInterrupt:
pass
finally:
print(f"\n{DIM}Cerrando...{RESET}")
client.stop()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
name: acai-code-mcp-prod
services:
redis:
image: redis:7-alpine
container_name: acai-redis-prod
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --save 60 1 --loglevel warning
acai-mcp-server:
build: .
image: acai-mcp-server
container_name: acai-mcp-server-prod
restart: unless-stopped
depends_on:
- redis
ports:
- "3000:3000" # MCP SSE endpoint (prod)
- "4545:4545" # Monitor UI
volumes:
- ./:/app
- /app/node_modules
- figma_shared:/app/figma_images
command: npm run dev
env_file:
- .env
environment:
- MCP_PORT=3000
- MCP_MONITOR_PORT=4545
- FIGMA_IMAGES_DIR=/app/figma_images
- REDIS_URL=redis://redis:6379
volumes:
redis-data:
figma_shared:
external: true

View File

@@ -0,0 +1,36 @@
name: acai-code-mcp
services:
redis:
image: redis:7-alpine
container_name: acai-redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --save 60 1 --loglevel warning
acai-mcp-server:
build: .
image: acai-mcp-server
container_name: acai-mcp-server
restart: unless-stopped
depends_on:
- redis
ports:
- "3010:3010" # MCP SSE endpoint
- "4545:4545" # Monitor UI
volumes:
- ./:/app
- /app/node_modules
command: npm run dev
env_file:
- .env
environment:
- MCP_PORT=3010
- MCP_MONITOR_PORT=4545
- REDIS_URL=redis://redis:6379
volumes:
redis-data:

85
mcp-server/fieldData.json Normal file
View File

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

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* HTTP MCP Client Bridge
* Acts as a bridge between:
* - HTTP Server (StreamableHTTPServerTransport at /mcp endpoint)
* - Claude Code (via stdio)
*
* Usage: node http-mcp-client.js <url> [--header name:value] ...
* Example: node http-mcp-client.js http://localhost:3000/mcp --header X-Acai-Token:abc123
*/
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/http.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: node http-mcp-client.js <url> [--header name:value] ...");
console.error("Example: node http-mcp-client.js http://localhost:3000/mcp --header X-Acai-Token:abc123");
process.exit(1);
}
const url = args[0];
const headers = {};
// Parse headers from command line
for (let i = 1; i < args.length; i += 2) {
if (args[i] === "--header" && i + 1 < args.length) {
const [name, value] = args[i + 1].split(":");
if (name && value) {
headers[name.trim()] = value.trim();
}
}
}
console.error(`[HTTP Bridge] Connecting to ${url}`);
console.error(`[HTTP Bridge] Headers: ${Object.keys(headers).join(", ")}`);
try {
// Create StreamableHTTP client transport to connect to our HTTP server
// This transport supports the modern HTTP/EventStream protocol (2025-03-26)
const httpTransport = new StreamableHTTPClientTransport(new URL(url), {
headers
});
// Create stdio transport for Claude Code communication
const stdioTransport = new StdioClientTransport();
// Create MCP client connected to HTTP server
const client = new Client({
name: "acai-http-bridge",
version: "1.0.0"
});
console.error("[HTTP Bridge] Connecting to HTTP server...");
await client.connect(httpTransport);
console.error("[HTTP Bridge] Connected to HTTP server successfully");
// Now bridge the client's tools to Claude Code via stdio
console.error("[HTTP Bridge] Forwarding tools to Claude Code via stdio...");
await stdioTransport.start(client);
console.error("[HTTP Bridge] Bridge active and ready");
} catch (error) {
console.error("[HTTP Bridge] ERROR:", error.message);
if (error.stack) {
console.error(error.stack);
}
process.exit(1);
}
}
main();

788
mcp-server/httpServer.js Normal file
View File

@@ -0,0 +1,788 @@
import fs from "fs";
import path from "path";
import os from "os";
import crypto from "node:crypto";
import express from "express";
import cors from "cors";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { MCP_PORT, JWT_SECRET } from "./config/index.js";
import {
sessionCredentials,
sessionApiClients,
sessionServers,
clearSessionCredentials,
setSessionUserToken,
setMcpSessionCredentials,
getMcpSessionCredentials
} from "./auth/index.js";
import { fetchProjectInfo } from "./auth/localClient.js";
import { createSessionServer } from "./server.js";
// Active sessions - stores { transport, server, type, heartbeatInterval }
const activeSessions = new Map();
const ACCESS_TOKEN_TTL = 3600; // seconds
const base64url = (value) => Buffer.from(value).toString("base64url");
const previewToken = (value) => {
if (!value) return "<empty>";
if (value.length <= 16) return value;
return `${value.slice(0, 12)}...${value.slice(-8)} (len=${value.length})`;
};
const signJwt = (payload, expiresInSeconds = ACCESS_TOKEN_TTL) => {
const header = { alg: "HS256", typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const enrichedPayload = { iat: now, exp: now + expiresInSeconds, ...payload };
const headerEncoded = base64url(JSON.stringify(header));
const payloadEncoded = base64url(JSON.stringify(enrichedPayload));
const signature = crypto
.createHmac("sha256", JWT_SECRET)
.update(`${headerEncoded}.${payloadEncoded}`)
.digest("base64url");
return `${headerEncoded}.${payloadEncoded}.${signature}`;
};
const verifyJwt = (token) => {
try {
const [headerEncoded, payloadEncoded, signature] = token.split(".");
if (!headerEncoded || !payloadEncoded || !signature) {
return null;
}
const expected = crypto
.createHmac("sha256", JWT_SECRET)
.update(`${headerEncoded}.${payloadEncoded}`)
.digest("base64url");
if (expected !== signature) {
return null;
}
const payload = JSON.parse(Buffer.from(payloadEncoded, "base64url").toString());
if (payload.exp && Math.floor(Date.now() / 1000) > payload.exp) {
return null;
}
return payload;
} catch (error) {
console.error(`[MCP HTTP] JWT verification error: ${error.message}`);
return null;
}
};
const resolveProjectCredentials = async (projectName) => {
try {
const info = await fetchProjectInfo(projectName);
if (!info.success) {
throw new Error(info.error || "Failed to resolve project info");
}
return {
token: info.token,
tokenHash: info.tokenHash || null,
website: info.domain,
web_url: info.web_url,
api_web_url: info.api_web_url || info.web_url,
forge_host: info.forge_host || null,
};
} catch (error) {
throw new Error(`Failed to resolve project '${projectName}': ${error.message}`);
}
};
/**
* Configure credentials from request headers/query params for a session
*/
const configureSessionCredentials = async (sessionId, { token, tokenHash, website, web_url, userToken, projectName }) => {
// Priority 1: Resolve via project name from local Python server
if (projectName) {
try {
const projectCreds = await resolveProjectCredentials(projectName);
sessionCredentials.set(sessionId, {
token: projectCreds.token,
tokenHash: projectCreds.tokenHash || null,
website: projectCreds.website,
web_url: projectCreds.web_url,
api_web_url: projectCreds.api_web_url || projectCreds.web_url,
forge_host: projectCreds.forge_host || null,
profileName: 'project-' + projectName,
role: 'developer',
});
console.log(`[MCP] Session ${sessionId} authenticated via project '${projectName}' - web_url: ${projectCreds.web_url}`);
return true;
} catch (error) {
console.error(`[MCP] Failed to resolve project '${projectName}': ${error.message}`);
// Fall through to try other auth methods
}
}
// Priority 2: Direct credentials (legacy header-based)
if (token && website) {
sessionCredentials.set(sessionId, {
token,
tokenHash: tokenHash || null,
website,
web_url: web_url || `https://${website}`,
api_web_url: web_url || `https://${website}`,
forge_host: null,
profileName: 'http-session',
role: 'developer',
});
console.log(`[MCP] Session ${sessionId} authenticated - website: ${website}`);
return true;
} else if (userToken) {
setSessionUserToken(userToken, sessionId);
console.log(`[MCP] Session ${sessionId} with userToken for auto-login`);
return true;
}
return false;
};
/**
* Extract credentials from request (headers or query params)
*/
const extractCredentialsFromRequest = (req) => {
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
return {
projectName: url.searchParams.get('project') || req.headers['x-project-name'],
token: url.searchParams.get('token') || req.headers['x-acai-token'],
tokenHash: url.searchParams.get('tokenHash') || req.headers['x-acai-token-hash'],
website: url.searchParams.get('website') || req.headers['x-acai-website'],
userToken: url.searchParams.get('userToken') || req.headers['x-user-token']
};
};
/**
* Create and start the MCP HTTP server with both Streamable HTTP and SSE transports
*/
export function startHttpServer() {
const app = express();
// Parse JSON and URL-encoded bodies
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Configure CORS
app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'X-Acai-Token', 'X-Acai-Website', 'X-Acai-Token-Hash', 'X-User-Token', 'X-Project-Name', 'Authorization', 'Mcp-Session-Id'],
exposedHeaders: ['Mcp-Session-Id'],
credentials: true
}));
//=============================================================================
// STREAMABLE HTTP TRANSPORT (PROTOCOL VERSION 2025-03-26)
// This is the new recommended transport for MCP
//=============================================================================
app.all('/mcp', async (req, res) => {
console.log(`[MCP Streamable] ${req.method} /mcp`);
try {
const mcpSessionId = req.headers['mcp-session-id'];
let transport;
if (mcpSessionId && activeSessions.has(mcpSessionId)) {
// Reuse existing transport
const session = activeSessions.get(mcpSessionId);
if (session.type === 'streamable') {
transport = session.transport;
console.log(`[MCP Streamable] Reusing session ${mcpSessionId.substring(0, 8)}...`);
} else {
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: Session exists but uses a different transport protocol'
},
id: null
});
return;
}
} else if (!mcpSessionId && req.method === 'POST' && isInitializeRequest(req.body)) {
// New initialization request - create new transport
console.log(`[MCP Streamable] New initialization request`);
// Extract credentials from request
const credentials = extractCredentialsFromRequest(req);
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (sessionId) => {
console.log(`[MCP Streamable] Session initialized: ${sessionId.substring(0, 8)}...`);
// Store the transport
activeSessions.set(sessionId, {
transport,
server: null, // Will be set after connect
type: 'streamable',
startTime: Date.now()
});
// Configure credentials for this session (async, fire-and-forget)
configureSessionCredentials(sessionId, credentials).then((configured) => {
if (configured) {
// Also store credentials by MCP-Session-Id for persistence
const creds = sessionCredentials.get(sessionId);
if (creds) {
setMcpSessionCredentials(sessionId, creds);
}
}
}).catch((err) => {
console.error(`[MCP Streamable] Error configuring credentials for session ${sessionId}:`, err.message);
});
},
onsessionclosed: (sessionId) => {
console.log(`[MCP Streamable] Session closed: ${sessionId.substring(0, 8)}...`);
activeSessions.delete(sessionId);
clearSessionCredentials(sessionId);
}
});
// Set up onclose handler
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && activeSessions.has(sid)) {
console.log(`[MCP Streamable] Transport closed for session ${sid.substring(0, 8)}...`);
activeSessions.delete(sid);
clearSessionCredentials(sid);
}
};
// Create session-specific server and connect
const sessionServer = createSessionServer();
await sessionServer.connect(transport);
// Store session server for role filtering
if (transport.sessionId) {
sessionServers.set(transport.sessionId, sessionServer);
}
// Update session with server reference
if (transport.sessionId) {
const session = activeSessions.get(transport.sessionId);
if (session) {
session.server = sessionServer;
}
}
} else if (mcpSessionId) {
// Session ID provided but session not found - try to recover credentials
const savedCreds = getMcpSessionCredentials(mcpSessionId);
if (savedCreds) {
console.log(`[MCP Streamable] Recovering credentials for session ${mcpSessionId.substring(0, 8)}...`);
// Session might have been lost due to server restart - need to re-initialize
}
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: Session not found. Please reinitialize.'
},
id: null
});
return;
} else {
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided and not an initialization request'
},
id: null
});
return;
}
// Handle the request with the transport
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('[MCP Streamable] Error:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error'
},
id: null
});
}
}
});
//=============================================================================
// DEPRECATED HTTP+SSE TRANSPORT (PROTOCOL VERSION 2024-11-05)
// Kept for backwards compatibility with older clients
//=============================================================================
// SSE connection endpoint (GET /sse)
app.get('/sse', async (req, res) => {
console.log(`[MCP SSE] New SSE connection`);
const credentials = extractCredentialsFromRequest(req);
// Create SSE transport
const transport = new SSEServerTransport("/message", res);
const sessionId = transport.sessionId;
console.log(`[MCP SSE] Session created: ${sessionId}`);
// Create session-specific server
const sessionServer = createSessionServer();
// Store session server for role filtering
sessionServers.set(sessionId, sessionServer);
// Set up heartbeat
const heartbeatInterval = setInterval(() => {
try {
if (res.writableEnded || res.destroyed) {
clearInterval(heartbeatInterval);
return;
}
res.write(': heartbeat\n\n');
} catch (err) {
console.error(`[MCP SSE] Heartbeat error for session ${sessionId}:`, err.message);
clearInterval(heartbeatInterval);
}
}, 30000);
// Configure credentials
await configureSessionCredentials(sessionId, credentials);
// Store session
activeSessions.set(sessionId, {
transport,
server: sessionServer,
type: 'sse',
heartbeatInterval,
startTime: Date.now()
});
// Handle close
res.on('close', () => {
console.log(`[MCP SSE] Connection closed for session ${sessionId}`);
});
transport.onclose = () => {
const session = activeSessions.get(sessionId);
if (!session) return;
activeSessions.delete(sessionId);
if (session.heartbeatInterval) {
clearInterval(session.heartbeatInterval);
}
clearSessionCredentials(sessionId);
console.log(`[MCP SSE] Session ${sessionId} cleaned up`);
};
try {
await sessionServer.connect(transport);
console.log(`[MCP SSE] Session ${sessionId} connected`);
} catch (error) {
console.error(`[MCP SSE] Connection error for session ${sessionId}:`, error.message);
activeSessions.delete(sessionId);
clearSessionCredentials(sessionId);
}
});
// Message endpoint for SSE transport (POST /message)
app.post('/message', async (req, res) => {
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
const sessionId = url.searchParams.get("sessionId");
if (!sessionId) {
res.status(400).json({ error: "Missing session ID" });
return;
}
const session = activeSessions.get(sessionId);
if (!session || session.type !== 'sse') {
res.status(404).json({ error: "Session not found" });
return;
}
const { transport, heartbeatInterval } = session;
// Check if SSE connection is still alive
const sseResponse = transport._sseResponse;
if (!sseResponse || sseResponse.writableEnded || sseResponse.destroyed) {
activeSessions.delete(sessionId);
clearSessionCredentials(sessionId);
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
}
res.status(410).json({ error: "SSE connection closed", code: "SSE_CLOSED" });
return;
}
try {
await transport.handlePostMessage(req, res, req.body);
} catch (error) {
console.error(`[MCP SSE] POST error for session ${sessionId}:`, error.message);
if (!res.headersSent) {
res.status(500).json({ error: error.message });
}
}
});
// Root path normalization (for clients that call "/" instead of /sse or /mcp)
app.get('/', (req, res) => {
// Redirect to SSE for backwards compatibility
res.redirect('/sse');
});
app.post('/', async (req, res) => {
// Check if it's a Streamable HTTP initialize request
if (isInitializeRequest(req.body)) {
// Forward to /mcp
req.url = '/mcp';
app.handle(req, res);
} else {
// Forward to /message (needs sessionId in query)
req.url = '/message';
app.handle(req, res);
}
});
//=============================================================================
// HEALTH CHECK
//=============================================================================
app.get('/health', (req, res) => {
const sseCount = Array.from(activeSessions.values()).filter(s => s.type === 'sse').length;
const streamableCount = Array.from(activeSessions.values()).filter(s => s.type === 'streamable').length;
res.json({
status: "ok",
activeSessions: activeSessions.size,
sse: sseCount,
streamable: streamableCount,
mode: "hybrid"
});
});
//=============================================================================
// OAUTH2 ENDPOINTS
//=============================================================================
// OAuth2 Authorization Server Metadata endpoint (per RFC8414)
app.get('/.well-known/oauth-authorization-server', (req, res) => {
const baseUrl = `https://${req.headers.host}`;
res.json({
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/authorize`,
token_endpoint: `${baseUrl}/token`,
registration_endpoint: `${baseUrl}/register`,
grant_types_supported: ["authorization_code", "client_credentials"],
response_types_supported: ["code"],
token_endpoint_auth_methods_supported: ["client_secret_post", "none"],
service_documentation: `${baseUrl}/docs`,
code_challenge_methods_supported: ["S256"],
mcp_endpoint: `${baseUrl}/mcp`
});
});
// OAuth2 Dynamic Client Registration endpoint (per RFC7591)
app.post('/register', (req, res) => {
const clientInfo = req.body;
console.error(`[MCP HTTP] POST /register - Client registration request:`, clientInfo);
const clientId = `acai-${Date.now()}-${Math.random().toString(36).substring(7)}`;
res.status(201).json({
client_id: clientId,
client_id_issued_at: Math.floor(Date.now() / 1000),
redirect_uris: clientInfo.redirect_uris || [],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code"],
response_types: ["code"]
});
console.error(`[MCP HTTP] POST /register - Registered client: ${clientId}`);
});
// OAuth2 Authorization endpoint
app.get('/authorize', (req, res) => {
const { client_id: clientId, redirect_uri: redirectUri, state, code_challenge: codeChallenge, code_challenge_method: codeChallengeMethod = "S256" } = req.query;
console.error(`[MCP HTTP] GET /authorize - client_id: ${clientId}, redirect_uri: ${redirectUri}`);
if (!clientId || !redirectUri) {
res.status(400).json({
error: "invalid_request",
error_description: "Missing client_id or redirect_uri"
});
return;
}
const code = Buffer.from(JSON.stringify({
client_id: clientId,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
timestamp: Date.now(),
expires_at: Date.now() + (10 * 60 * 1000)
})).toString('base64');
const callback = new URL(redirectUri);
callback.searchParams.set("code", code);
if (state) callback.searchParams.set("state", state);
console.error(`[MCP HTTP] GET /authorize - Redirecting to: ${callback.toString()}`);
res.redirect(302, callback.toString());
});
// OAuth2 Token endpoint
app.post('/token', async (req, res) => {
console.error(`[MCP HTTP] POST /token - Received token request`);
try {
let params = req.body;
// Handle form-encoded body
const contentType = req.headers['content-type'] || '';
if (contentType.includes('application/x-www-form-urlencoded') && typeof req.body === 'string') {
const searchParams = new URLSearchParams(req.body);
params = Object.fromEntries(searchParams);
}
const { grant_type, client_secret, code, code_verifier } = params;
console.error(`[MCP HTTP] POST /token - grant_type: ${grant_type}, has_code: ${!!code}, has_client_secret: ${!!client_secret}`);
if (grant_type === 'authorization_code') {
if (!code) {
res.status(400).json({
error: "invalid_request",
error_description: "Missing authorization code"
});
return;
}
try {
const decodedCode = JSON.parse(Buffer.from(code, 'base64').toString());
if (decodedCode.expires_at && Date.now() > decodedCode.expires_at) {
res.status(400).json({
error: "invalid_grant",
error_description: "Authorization code has expired"
});
return;
}
if (decodedCode.code_challenge && code_verifier) {
const digest = crypto.createHash('sha256').update(code_verifier).digest('base64url');
if (digest !== decodedCode.code_challenge) {
res.status(400).json({
error: "invalid_grant",
error_description: "PKCE code verifier mismatch"
});
return;
}
}
// client_secret = project name to resolve via local server
if (!client_secret) {
res.status(400).json({
error: "invalid_request",
error_description: "Missing client_secret (should be project name)"
});
return;
}
const projectCreds = await resolveProjectCredentials(client_secret);
const accessToken = signJwt({
acaiToken: projectCreds.token,
acaiTokenHash: projectCreds.tokenHash,
website: projectCreds.website,
web_url: projectCreds.web_url,
clientId: decodedCode.client_id,
tokenType: "acai-credentials",
});
res.setHeader("Cache-Control", "no-store");
res.setHeader("Pragma", "no-cache");
res.json({
access_token: accessToken,
token_type: "Bearer",
expires_in: ACCESS_TOKEN_TTL
});
} catch (error) {
console.error(`[MCP HTTP] POST /token (auth_code) - ERROR:`, error.message);
res.status(400).json({
error: "invalid_grant",
error_description: "Invalid authorization code"
});
}
return;
}
if (grant_type !== 'client_credentials') {
res.status(400).json({
error: "unsupported_grant_type",
error_description: "Only 'client_credentials' and 'authorization_code' grant types are supported"
});
return;
}
// client_secret = project name to resolve via local server
if (!client_secret) {
res.status(400).json({
error: "invalid_request",
error_description: "Missing client_secret (should be project name)"
});
return;
}
const projectCreds = await resolveProjectCredentials(client_secret);
const accessToken = signJwt({
acaiToken: projectCreds.token,
acaiTokenHash: projectCreds.tokenHash,
website: projectCreds.website,
web_url: projectCreds.web_url,
clientId: params.client_id || "client_credentials",
tokenType: "acai-credentials",
});
res.setHeader("Cache-Control", "no-store");
res.setHeader("Pragma", "no-cache");
res.json({
access_token: accessToken,
token_type: "Bearer",
expires_in: ACCESS_TOKEN_TTL
});
} catch (error) {
console.error(`[MCP HTTP] POST /token - ERROR:`, error.message);
const isAuthError = error.message?.toLowerCase().includes("exchange");
res.status(isAuthError ? 400 : 500).json({
error: isAuthError ? "invalid_grant" : "server_error",
error_description: error.message
});
}
});
//=============================================================================
// STATIC FILE SERVING
//=============================================================================
// Serve Figma images
app.get('/figma-images/:fileName', (req, res) => {
const { fileName } = req.params;
if (!fileName) {
res.status(400).json({ error: "File name required" });
return;
}
try {
const figmaDir = process.env.FIGMA_IMAGES_DIR || '/app/figma_images';
const filePath = path.join(figmaDir, fileName);
const resolvedPath = path.resolve(filePath);
const resolvedFigmaDir = path.resolve(figmaDir);
if (!resolvedPath.startsWith(resolvedFigmaDir)) {
res.status(403).json({ error: "Access denied" });
return;
}
if (!fs.existsSync(filePath)) {
res.status(404).json({ error: "File not found", path: fileName });
return;
}
const ext = path.extname(fileName).toLowerCase();
const contentTypes = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml'
};
const contentType = contentTypes[ext] || 'application/octet-stream';
const fileBuffer = fs.readFileSync(filePath);
res.setHeader("Content-Type", contentType);
res.setHeader("Content-Length", fileBuffer.length);
res.setHeader("Cache-Control", "public, max-age=3600");
res.send(fileBuffer);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Serve generated images
app.get('/generated-images/:fileId', (req, res) => {
const { fileId } = req.params;
if (!fileId) {
res.status(400).json({ error: "File ID required" });
return;
}
try {
const tempDir = process.env.GENERATED_IMAGES_TEMP_DIR || path.join(os.tmpdir(), 'generated-images');
const filename = `generated-${fileId}.jpg`;
const filePath = path.join(tempDir, filename);
const resolvedPath = path.resolve(filePath);
const resolvedTempDir = path.resolve(tempDir);
if (!resolvedPath.startsWith(resolvedTempDir)) {
res.status(403).json({ error: "Access denied" });
return;
}
if (!fs.existsSync(filePath)) {
res.status(404).json({ error: "File not found" });
return;
}
const fileBuffer = fs.readFileSync(filePath);
res.setHeader("Content-Type", "image/jpeg");
res.setHeader("Content-Length", fileBuffer.length);
res.setHeader("Cache-Control", "public, max-age=3600");
res.send(fileBuffer);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
//=============================================================================
// START SERVER
//=============================================================================
const server = app.listen(MCP_PORT, '0.0.0.0', () => {
console.error(`[MCP] Server listening on http://0.0.0.0:${MCP_PORT}`);
console.error(`[MCP] Streamable HTTP endpoint: /mcp (recommended)`);
console.error(`[MCP] Legacy SSE endpoint: /sse (backwards compatible)`);
console.error(`[MCP] Provide credentials via headers: X-Acai-Token, X-Acai-Website, X-Acai-Token-Hash`);
});
server.on("error", (error) => {
console.error(`[MCP] Server error:`, error);
});
// Handle shutdown
process.on('SIGINT', async () => {
console.log('[MCP] Shutting down server...');
for (const [sessionId, session] of activeSessions.entries()) {
try {
console.log(`[MCP] Closing session ${sessionId}`);
if (session.transport.close) {
await session.transport.close();
}
if (session.heartbeatInterval) {
clearInterval(session.heartbeatInterval);
}
} catch (error) {
console.error(`[MCP] Error closing session ${sessionId}:`, error);
}
}
activeSessions.clear();
console.log('[MCP] Shutdown complete');
process.exit(0);
});
return server;
}
export { activeSessions };

View File

@@ -0,0 +1,234 @@
import http from "node:http";
import fs from "fs";
import path from "path";
import os from "os";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { MCP_PORT } from "./config/index.js";
import {
sessionCredentials,
sessionApiClients,
clearSessionCredentials
} from "./auth/index.js";
// Active SSE sessions
const activeSessions = new Map();
/**
* Create and start the MCP HTTP server for SSE transport
*/
export function startHttpServer(server) {
const mcpHttpServer = http.createServer(async (req, res) => {
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
// Handle SSE connection (GET /sse)
if (req.method === "GET" && url.pathname === "/sse") {
console.error(`[MCP HTTP] GET /sse - New SSE connection from ${req.headers.origin || req.socket.remoteAddress}`);
// Extract credentials from headers
const token = req.headers['x-acai-token'];
const tokenHash = req.headers['x-acai-token-hash'];
const website = req.headers['x-acai-website'];
// Create SSE transport
const transport = new SSEServerTransport("/message", res);
const sessionId = transport.sessionId;
// Wrap the send method to add logging and fix SSE format
const originalSend = transport.send.bind(transport);
transport.send = async (message) => {
try {
if (!transport._sseResponse) {
throw new Error('Not connected');
}
// Serialize message and ensure it's valid for SSE format
const messageJson = JSON.stringify(message);
console.error(`[MCP HTTP] SSE.send - Session ${sessionId} - Sending message (${messageJson.substring(0, 100)}...)`);
// SSE format: properly handle multiline data
// If JSON contains newlines, each line must be prefixed with "data: "
const lines = messageJson.split('\n');
let sseData = 'event: message\n';
if (lines.length === 1) {
// Single line JSON
sseData += `data: ${messageJson}\n\n`;
} else {
// Multiline JSON - prefix each line
for (const line of lines) {
sseData += `data: ${line}\n`;
}
sseData += '\n';
}
transport._sseResponse.write(sseData);
console.error(`[MCP HTTP] SSE.send - Session ${sessionId} - Message sent successfully (${sseData.length} bytes)`);
} catch (error) {
console.error(`[MCP HTTP] SSE.send - Session ${sessionId} - ERROR sending message:`, error.message);
throw error;
}
};
console.error(`[MCP HTTP] GET /sse - Session ${sessionId} created`);
// Store credentials for this session
if (token && website) {
sessionCredentials.set(sessionId, {
token,
tokenHash: tokenHash || null,
website,
profileName: 'http-session'
});
console.error(`[MCP HTTP] GET /sse - Session ${sessionId} authenticated for ${website}`);
} else {
console.warn(`[MCP HTTP] GET /sse - Session ${sessionId} started without credentials`);
}
// Store session
activeSessions.set(sessionId, transport);
// Handle transport close
transport.onclose = () => {
console.error(`[MCP HTTP] GET /sse - Session ${sessionId} closed`);
activeSessions.delete(sessionId);
clearSessionCredentials(sessionId);
};
// Connect server to transport
try {
console.error(`[MCP HTTP] GET /sse - Session ${sessionId} connecting server to transport...`);
await server.connect(transport);
console.error(`[MCP HTTP] GET /sse - Session ${sessionId} server connected successfully`);
} catch (error) {
console.error(`[MCP HTTP] GET /sse - Session ${sessionId} ERROR:`, error);
activeSessions.delete(sessionId);
clearSessionCredentials(sessionId);
}
return;
}
// Handle POST messages (POST /message?sessionId=xxx)
if (req.method === "POST" && url.pathname === "/message") {
const sessionId = url.searchParams.get("sessionId");
if (!sessionId) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Missing session ID" }));
return;
}
const transport = activeSessions.get(sessionId);
if (!transport) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Session not found" }));
return;
}
// Read request body
let body = "";
req.on("data", (chunk) => {
body += chunk.toString();
});
req.on("end", async () => {
try {
console.error(`[MCP HTTP] POST /message - Session ${sessionId} - Received request, parsing...`);
const parsedBody = JSON.parse(body);
console.error(`[MCP HTTP] POST /message - Session ${sessionId} - Parsed body: ${JSON.stringify(parsedBody).substring(0, 100)}...`);
// Track when handlePostMessage starts and ends
const beforeTime = Date.now();
await transport.handlePostMessage(req, res, parsedBody);
const afterTime = Date.now();
console.error(`[MCP HTTP] POST /message - Session ${sessionId} - handlePostMessage completed (took ${afterTime - beforeTime}ms)`);
console.error(`[MCP HTTP] POST /message - Session ${sessionId} - Response headersSent: ${res.headersSent}, writableEnded: ${res.writableEnded}`);
} catch (error) {
console.error(`[MCP HTTP] POST /message - Session ${sessionId} - ERROR:`, error.message);
console.error(`[MCP HTTP] POST /message - Session ${sessionId} - Stack:`, error.stack);
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: error.message }));
}
}
});
return;
}
// Health check
if (req.method === "GET" && url.pathname === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
status: "ok",
activeSessions: activeSessions.size,
mode: "sse"
}));
return;
}
// Serve generated images from temp folder
if (req.method === "GET" && url.pathname.startsWith("/generated-images/")) {
const fileId = url.pathname.split("/generated-images/")[1];
if (!fileId) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "File ID required" }));
return;
}
try {
const tempDir = process.env.GENERATED_IMAGES_TEMP_DIR || path.join(os.tmpdir(), 'generated-images');
const filename = `generated-${fileId}.jpg`;
const filePath = path.join(tempDir, filename);
// Security: ensure file is within temp directory (prevent path traversal)
const resolvedPath = path.resolve(filePath);
const resolvedTempDir = path.resolve(tempDir);
if (!resolvedPath.startsWith(resolvedTempDir)) {
res.writeHead(403, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Access denied" }));
return;
}
// Check if file exists
if (!fs.existsSync(filePath)) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "File not found" }));
return;
}
// Read and serve file
const fileBuffer = fs.readFileSync(filePath);
res.writeHead(200, {
"Content-Type": "image/jpeg",
"Content-Length": fileBuffer.length,
"Cache-Control": "public, max-age=3600"
});
res.end(fileBuffer);
return;
} catch (error) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: error.message }));
return;
}
}
// 404 for other routes
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
});
mcpHttpServer.on("error", (error) => {
console.error(`[MCP] HTTP server error:`, error);
});
mcpHttpServer.listen(MCP_PORT, () => {
console.error(`[MCP] SSE server listening on http://localhost:${MCP_PORT}/sse`);
console.error(`[MCP] Clients should connect to: http://localhost:${MCP_PORT}/sse`);
console.error(`[MCP] Provide credentials via headers: X-Acai-Token, X-Acai-Website, X-Acai-Token-Hash`);
});
return mcpHttpServer;
}
export { activeSessions };

48
mcp-server/index.js Normal file
View File

@@ -0,0 +1,48 @@
/**
* Acai Code MCP Server - Entry Point
*
* This is the main entry point for the MCP server.
* All functionality is modularized in separate files for better maintainability.
*/
// Load configuration first
import { loadLocalConfigProfile, applyProfileToEnv } from "./config/index.js";
// Load and apply config profile (backward compatibility)
const selectedProfile = loadLocalConfigProfile();
applyProfileToEnv(selectedProfile);
console.error("[MCP] Server starting in SSE mode. Credentials will be provided per-session via HTTP headers.");
// Import core modules
import { createMcpServer, createRequestMonitor, toolHandlers, setRegistrationFunctions } from "./server.js";
import { startHttpServer } from "./httpServer.js";
import { startMonitorServer } from "./monitor.js";
// Import registration functions
import { registerPrompts } from "./prompts/index.js";
import { registerTools } from "./tools/index.js";
import { registerResources } from "./resources/index.js";
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// IMPORTANT: Set registration functions BEFORE starting HTTP server
// Each session creates its own server instance with these functions
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
setRegistrationFunctions({ registerPrompts, registerTools, registerResources });
// Create the shared request monitor (will be applied to each session server)
const requestMonitor = createRequestMonitor();
// Create a server instance for retry functionality in the monitor UI
const server = createMcpServer();
registerPrompts(server);
registerTools(server);
registerResources(server);
// Start HTTP server for SSE transport
// Each session will create its own server instance via createSessionServer()
startHttpServer();
// Start monitor server (if not disabled)
startMonitorServer(requestMonitor, toolHandlers);

44
mcp-server/index.new.js Normal file
View File

@@ -0,0 +1,44 @@
/**
* Acai Code MCP Server - Entry Point
*
* This is the main entry point for the MCP server.
* All functionality is modularized in separate files for better maintainability.
*/
// Load configuration first
import { loadLocalConfigProfile, applyProfileToEnv } from "./config/index.js";
// Load and apply config profile (backward compatibility)
const selectedProfile = loadLocalConfigProfile();
applyProfileToEnv(selectedProfile);
console.error("[MCP] Server starting in SSE mode. Credentials will be provided per-session via HTTP headers.");
// Import core modules
import { createMcpServer, createRequestMonitor, toolHandlers } from "./server.js";
import { startHttpServer } from "./httpServer.js";
import { startMonitorServer } from "./monitor.js";
// Import registration functions
import { registerPrompts } from "./prompts/index.js";
import { registerTools } from "./tools/index.js";
// Create and configure MCP server
const server = createMcpServer();
// Create request monitor
const requestMonitor = createRequestMonitor(server);
// Register all prompts
registerPrompts(server);
// Register all tools
registerTools(server);
// Start HTTP server for SSE transport
startHttpServer(server);
// Start monitor server (if not disabled)
startMonitorServer(requestMonitor, toolHandlers);

917
mcp-server/monitor.html Normal file
View File

@@ -0,0 +1,917 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Monitor MCP</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, "SF Pro Display", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background-color: #0f172a;
color: #e2e8f0;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
background: #0f172a;
}
header {
padding: 1rem 1.5rem;
border-bottom: 1px solid #1e293b;
display: flex;
justify-content: space-between;
align-items: center;
}
main {
flex: 1;
display: grid;
grid-template-columns: minmax(260px, 360px) 1fr;
gap: 1px;
background: #1e293b;
}
section {
background: #0f172a;
overflow: hidden;
}
.list {
display: flex;
flex-direction: column;
}
.list-header {
padding: 0.75rem 1rem;
border-bottom: 1px solid #1e293b;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #94a3b8;
}
.request-list {
flex: 1;
overflow-y: auto;
}
.request-item {
padding: 0.85rem 1rem;
border-bottom: 1px solid #1e293b;
cursor: pointer;
transition: background 0.2s ease;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.request-item:hover {
background: rgba(59, 130, 246, 0.08);
}
.request-item.active {
background: rgba(59, 130, 246, 0.15);
border-left: 3px solid #3b82f6;
padding-left: calc(1rem - 3px);
}
.request-top {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
}
.request-meta {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: #94a3b8;
}
.status {
padding: 0.1rem 0.45rem;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status.pending {
background: rgba(251, 191, 36, 0.15);
color: #facc15;
}
.status.success {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
.status.error {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.details {
display: flex;
flex-direction: column;
}
.details-header {
border-bottom: 1px solid #1e293b;
padding: 0.75rem 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.details-content {
flex: 1;
overflow: auto;
padding: 1.25rem;
}
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 1rem;
}
.detail-card {
background: rgba(15, 23, 42, 0.6);
border: 1px solid #1e293b;
border-radius: 0.75rem;
padding: 1rem;
margin-bottom: 1rem;
}
.detail-card h2 {
margin: 0 0 0.75rem;
font-size: 1rem;
color: #f8fafc;
}
pre {
margin: 0;
padding: 0.75rem;
background: #020617;
border-radius: 0.5rem;
overflow-x: auto;
font-size: 0.85rem;
line-height: 1.4;
border: 1px solid #1e293b;
}
code {
font-family: "JetBrains Mono", "Fira Code", Consolas, monospace;
}
.pill {
padding: 0.2rem 0.65rem;
border-radius: 999px;
font-size: 0.75rem;
background: rgba(148, 163, 184, 0.2);
color: #cbd5f5;
}
.connection-status {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
}
.dot {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: #22c55e;
}
.dot.offline {
background: #ef4444;
}
/* Tabs */
.tabs {
display: flex;
gap: 0.5rem;
margin-left: 2rem;
}
.tab {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid #334155;
border-radius: 0.5rem;
color: #94a3b8;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.tab:hover {
background: rgba(59, 130, 246, 0.1);
border-color: #3b82f6;
}
.tab.active {
background: rgba(59, 130, 246, 0.2);
border-color: #3b82f6;
color: #3b82f6;
}
.tab-content {
display: none;
}
.tab-content.active {
display: flex;
flex: 1;
}
/* Sessions list */
.session-item {
padding: 1rem;
border-bottom: 1px solid #1e293b;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.session-item:hover {
background: rgba(59, 130, 246, 0.05);
}
.session-website {
font-weight: 600;
color: #f8fafc;
font-size: 1rem;
}
.session-website.no-website {
color: #94a3b8;
font-style: italic;
}
.session-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.8rem;
color: #94a3b8;
}
.session-id {
font-family: "JetBrains Mono", monospace;
background: rgba(148, 163, 184, 0.1);
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
}
.session-status {
display: flex;
align-items: center;
gap: 0.35rem;
}
.session-status .dot {
width: 0.5rem;
height: 0.5rem;
}
.sessions-summary {
padding: 0.75rem 1rem;
border-bottom: 1px solid #1e293b;
background: rgba(59, 130, 246, 0.05);
font-size: 0.85rem;
color: #94a3b8;
}
/* Stats tab */
.stats-session-card {
border: 1px solid #1e293b;
border-radius: 0.75rem;
margin: 1rem;
overflow: hidden;
}
.stats-session-header {
padding: 1rem;
background: rgba(59, 130, 246, 0.08);
border-bottom: 1px solid #1e293b;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.stats-session-header h3 {
margin: 0;
font-size: 1rem;
color: #f8fafc;
}
.stats-totals {
display: flex;
gap: 1.5rem;
font-size: 0.85rem;
color: #94a3b8;
}
.stats-totals .stat-value {
color: #e2e8f0;
font-weight: 600;
}
.tool-breakdown-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.tool-breakdown-table th {
text-align: left;
padding: 0.6rem 1rem;
color: #94a3b8;
font-weight: 500;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
border-bottom: 1px solid #1e293b;
}
.tool-breakdown-table td {
padding: 0.5rem 1rem;
border-bottom: 1px solid rgba(30, 41, 59, 0.5);
color: #e2e8f0;
}
.tool-breakdown-table tr:hover {
background: rgba(59, 130, 246, 0.05);
}
.chars-bar {
display: inline-block;
height: 6px;
border-radius: 3px;
background: #3b82f6;
min-width: 2px;
vertical-align: middle;
margin-right: 0.4rem;
}
.chars-bar.out {
background: #22c55e;
}
@media (max-width: 900px) {
main {
grid-template-columns: 1fr;
}
.details {
min-height: 50vh;
}
.tabs {
margin-left: 0;
margin-top: 0.5rem;
}
header {
flex-wrap: wrap;
}
}
</style>
</head>
<body>
<header>
<div style="display: flex; align-items: center;">
<div>
<h1 style="margin: 0; font-size: 1.25rem;">Monitor MCP</h1>
<p style="margin: 0; font-size: 0.85rem; color: #94a3b8;">Seguimiento en tiempo real</p>
</div>
<div class="tabs">
<button class="tab active" data-tab="requests" onclick="switchTab('requests')">Peticiones</button>
<button class="tab" data-tab="sessions" onclick="switchTab('sessions')">Sesiones <span id="sessionsCount" style="opacity:0.7">(0)</span></button>
<button class="tab" data-tab="stats" onclick="switchTab('stats')">Stats</button>
</div>
</div>
<div class="connection-status">
<span class="dot" id="conn-dot"></span>
<span id="conn-label">Conectando…</span>
</div>
</header>
<main>
<!-- Tab: Peticiones -->
<div class="tab-content active" id="tab-requests" style="display: contents;">
<section class="list">
<div class="list-header">Peticiones recientes</div>
<div class="request-list" id="requestList"></div>
</section>
<section class="details">
<div class="details-header">
<div>
<strong id="detailMethod"></strong>
<span class="pill" id="detailStatus">Sin selección</span>
</div>
<div style="font-size: 0.85rem; color: #94a3b8;" id="detailTimestamp"></div>
</div>
<div class="details-content" id="detailContent">
<div class="empty-state">Elige una petición para ver el payload y la respuesta.</div>
</div>
</section>
</div>
<!-- Tab: Sesiones -->
<div class="tab-content" id="tab-sessions" style="display: none; grid-column: 1 / -1;">
<section style="width: 100%; display: flex; flex-direction: column;">
<div class="sessions-summary" id="sessionsSummary">
Cargando sesiones...
</div>
<div class="request-list" id="sessionsList" style="flex: 1;"></div>
</section>
</div>
<!-- Tab: Stats -->
<div class="tab-content" id="tab-stats" style="display: none; grid-column: 1 / -1;">
<section style="width: 100%; display: flex; flex-direction: column; overflow-y: auto;">
<div class="sessions-summary" id="statsSummary">
Cargando estadisticas...
</div>
<div id="statsContent" style="flex: 1;"></div>
</section>
</div>
</main>
<script>
const requestListEl = document.getElementById("requestList");
const detailMethodEl = document.getElementById("detailMethod");
const detailStatusEl = document.getElementById("detailStatus");
const detailTimestampEl = document.getElementById("detailTimestamp");
const detailContentEl = document.getElementById("detailContent");
const connDot = document.getElementById("conn-dot");
const connLabel = document.getElementById("conn-label");
const sessionsListEl = document.getElementById("sessionsList");
const sessionsSummaryEl = document.getElementById("sessionsSummary");
const sessionsCountEl = document.getElementById("sessionsCount");
const summaries = new Map();
let sessions = [];
let sessionStats = [];
let selectedId = null;
let currentTab = "requests";
let filterSessionId = null;
function formatChars(chars) {
if (chars == null || chars === 0) return "0";
if (chars < 1000) return `${chars}`;
if (chars < 1000000) return `${(chars / 1000).toFixed(1)}K`;
return `${(chars / 1000000).toFixed(2)}M`;
}
function estimateTokens(chars) {
if (chars == null || chars === 0) return 0;
return Math.ceil(chars / 4);
}
function formatTokens(chars) {
const tokens = estimateTokens(chars);
if (tokens < 1000) return `~${tokens}`;
if (tokens < 1000000) return `~${(tokens / 1000).toFixed(1)}K`;
return `~${(tokens / 1000000).toFixed(2)}M`;
}
// Tab switching
function switchTab(tabName) {
currentTab = tabName;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelector(`.tab[data-tab="${tabName}"]`).classList.add('active');
document.getElementById('tab-requests').style.display = tabName === 'requests' ? 'contents' : 'none';
document.getElementById('tab-sessions').style.display = tabName === 'sessions' ? 'flex' : 'none';
document.getElementById('tab-stats').style.display = tabName === 'stats' ? 'flex' : 'none';
if (tabName === 'sessions') renderSessions();
if (tabName === 'stats') renderStats();
}
function formatDate(value) {
if (!value) return "—";
return new Date(value).toLocaleString();
}
function formatDuration(ms) {
if (ms == null) return "—";
if (ms < 1000) return `${ms} ms`;
return `${(ms / 1000).toFixed(2)} s`;
}
function setConnectionStatus(online) {
connDot.classList.toggle("offline", !online);
connLabel.textContent = online ? "Tiempo real conectado" : "Conexión perdida";
}
function formatDurationLong(ms) {
if (ms == null) return "—";
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
} else {
return `${seconds}s`;
}
}
function renderSessions() {
sessionsCountEl.textContent = `(${sessions.length})`;
if (!sessions.length) {
sessionsSummaryEl.textContent = "No hay sesiones activas";
sessionsListEl.innerHTML = '<div class="empty-state" style="padding: 2rem;">Sin sesiones activas.</div>';
return;
}
// Group by website
const byWebsite = {};
sessions.forEach(s => {
const key = s.website || '(sin website)';
if (!byWebsite[key]) byWebsite[key] = [];
byWebsite[key].push(s);
});
const websiteCount = Object.keys(byWebsite).length;
sessionsSummaryEl.textContent = `${sessions.length} sesión${sessions.length !== 1 ? 'es' : ''} activa${sessions.length !== 1 ? 's' : ''} en ${websiteCount} website${websiteCount !== 1 ? 's' : ''}`;
sessionsListEl.innerHTML = "";
sessions.forEach((session) => {
const div = document.createElement("div");
div.className = "session-item";
const websiteClass = session.website ? '' : 'no-website';
const websiteDisplay = session.website || '(sin credenciales)';
div.style.cursor = "pointer";
div.addEventListener("click", () => filterBySession(session.sessionId));
div.innerHTML = `
<div class="session-website ${websiteClass}">${websiteDisplay} <span style="font-size:0.75rem;font-weight:400;color:#94a3b8;">click to see requests</span></div>
<div class="session-meta">
<span class="session-status">
<span class="dot" style="background: ${session.hasToken ? '#22c55e' : '#f59e0b'}"></span>
${session.hasToken ? 'Autenticado' : 'Pendiente auth'}
</span>
<span>Duración: ${formatDurationLong(session.durationMs)}</span>
${session.profileName ? `<span>Perfil: ${session.profileName}</span>` : ''}
</div>
<div class="session-meta">
<span class="session-id">${session.sessionId}</span>
<span>Iniciada: ${formatDate(session.startTime)}</span>
</div>
`;
sessionsListEl.appendChild(div);
});
}
function renderStats() {
const statsSummaryEl = document.getElementById("statsSummary");
const statsContentEl = document.getElementById("statsContent");
if (!sessionStats.length) {
statsSummaryEl.textContent = "No hay estadisticas disponibles";
statsContentEl.innerHTML = '<div class="empty-state" style="padding: 2rem;">Sin datos de herramientas todavia.</div>';
return;
}
let totalTools = 0, totalReqChars = 0, totalResChars = 0;
sessionStats.forEach(s => {
totalTools += s.totalToolCalls;
totalReqChars += s.totalRequestChars;
totalResChars += s.totalResponseChars;
});
statsSummaryEl.textContent = `${sessionStats.length} sesion${sessionStats.length !== 1 ? 'es' : ''} | ${totalTools} tool calls | In: ${formatTokens(totalReqChars)} tokens (${formatChars(totalReqChars)} chars) | Out: ${formatTokens(totalResChars)} tokens (${formatChars(totalResChars)} chars)`;
statsContentEl.innerHTML = "";
let maxChars = 0;
sessionStats.forEach(s => {
Object.values(s.toolBreakdown).forEach(tb => {
const total = tb.requestChars + tb.responseChars;
if (total > maxChars) maxChars = total;
});
});
sessionStats.forEach(stat => {
const card = document.createElement("div");
card.className = "stats-session-card";
const sessionInfo = sessions.find(s => s.sessionId === stat.sessionId);
const websiteName = sessionInfo?.website || stat.sessionId.substring(0, 16) + "...";
const toolEntries = Object.entries(stat.toolBreakdown).sort((a, b) => (b[1].responseChars + b[1].requestChars) - (a[1].responseChars + a[1].requestChars));
let toolRowsHtml = "";
toolEntries.forEach(([name, tb]) => {
const reqBarWidth = maxChars > 0 ? Math.max(2, (tb.requestChars / maxChars) * 120) : 2;
const resBarWidth = maxChars > 0 ? Math.max(2, (tb.responseChars / maxChars) * 120) : 2;
const totalToolChars = tb.requestChars + tb.responseChars;
toolRowsHtml += `<tr>
<td style="font-family: 'JetBrains Mono', monospace; font-size: 0.8rem;">${name}</td>
<td style="text-align:center;">${tb.count}</td>
<td><span class="chars-bar" style="width:${reqBarWidth}px"></span>${formatChars(tb.requestChars)}</td>
<td><span class="chars-bar out" style="width:${resBarWidth}px"></span>${formatChars(tb.responseChars)}</td>
<td style="text-align:center;color:#a78bfa;font-weight:600;">${formatTokens(totalToolChars)}</td>
<td style="text-align:center;">${formatDuration(tb.avgDurationMs)}</td>
<td style="text-align:center; color: ${tb.errors > 0 ? '#f87171' : '#4ade80'};">${tb.errors}</td>
</tr>`;
});
card.style.cursor = "pointer";
card.addEventListener("click", () => filterBySession(stat.sessionId));
card.innerHTML = `
<div class="stats-session-header">
<h3>${websiteName} <span style="font-size:0.75rem;font-weight:400;color:#94a3b8;">click to see requests</span></h3>
<div class="stats-totals">
<span>Requests: <span class="stat-value">${stat.totalRequests}</span></span>
<span>Tool calls: <span class="stat-value">${stat.totalToolCalls}</span></span>
<span>In: <span class="stat-value">${formatTokens(stat.totalRequestChars)} tok</span> <span style="opacity:0.6">(${formatChars(stat.totalRequestChars)} chars)</span></span>
<span>Out: <span class="stat-value">${formatTokens(stat.totalResponseChars)} tok</span> <span style="opacity:0.6">(${formatChars(stat.totalResponseChars)} chars)</span></span>
<span>Errors: <span class="stat-value" style="color: ${stat.errorCount > 0 ? '#f87171' : '#4ade80'};">${stat.errorCount}</span></span>
</div>
</div>
${toolEntries.length > 0 ? `
<table class="tool-breakdown-table">
<thead><tr>
<th>Tool</th>
<th style="text-align:center;">Calls</th>
<th>Chars In</th>
<th>Chars Out</th>
<th style="text-align:center;">Est. Tokens</th>
<th style="text-align:center;">Avg Time</th>
<th style="text-align:center;">Errors</th>
</tr></thead>
<tbody>${toolRowsHtml}</tbody>
</table>` : '<div style="padding:1rem;color:#94a3b8;">No tool calls in this session.</div>'}
`;
statsContentEl.appendChild(card);
});
}
function filterBySession(sessionId) {
filterSessionId = sessionId;
selectedId = null;
detailMethodEl.textContent = "—";
detailStatusEl.textContent = "Sin selección";
detailStatusEl.className = "pill";
detailTimestampEl.textContent = "";
detailContentEl.innerHTML = '<div class="empty-state">Elige una petición para ver el payload y la respuesta.</div>';
switchTab('requests');
renderList();
}
function clearFilter() {
filterSessionId = null;
renderList();
}
function renderList() {
let items = Array.from(summaries.values()).sort((a, b) => {
return new Date(b.timestamp) - new Date(a.timestamp);
});
if (filterSessionId) {
items = items.filter(i => i.sessionId === filterSessionId);
}
// Update list header with filter info
const listHeaderEl = document.querySelector('.list-header');
if (filterSessionId) {
const sessionInfo = sessions.find(s => s.sessionId === filterSessionId);
const label = sessionInfo?.website || filterSessionId.substring(0, 12) + '...';
// Compute session-level stats for the filter banner
const stat = sessionStats.find(s => s.sessionId === filterSessionId);
const statsLine = stat ? ` | ${stat.totalToolCalls} calls | ${formatTokens(stat.totalRequestChars + stat.totalResponseChars)} tokens` : '';
listHeaderEl.innerHTML = `<span style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
<span style="color:#3b82f6;cursor:pointer;" onclick="clearFilter()">&#x2190; Todas</span>
<span>${label}${statsLine}</span>
<span style="opacity:0.6;">(${items.length} req)</span>
</span>`;
} else {
listHeaderEl.textContent = 'Peticiones recientes';
}
if (!items.length) {
requestListEl.innerHTML = '<div class="empty-state" style="padding: 2rem;">Sin peticiones todavía.</div>';
return;
}
requestListEl.innerHTML = "";
items.forEach((item) => {
const div = document.createElement("div");
div.className = "request-item";
if (item.id === selectedId) {
div.classList.add("active");
}
const toolLabel = item.toolName ? ` <span style="color:#94a3b8;font-weight:400;font-size:0.85rem;">${item.toolName}</span>` : "";
const charsLabel = item.responseChars ? ` · ${formatChars(item.responseChars)} chars` : "";
div.innerHTML = `
<div class="request-top">
<span>${item.method}${toolLabel}</span>
<span class="status ${item.status}">${item.status}</span>
</div>
<div class="request-meta">
<span>${formatDate(item.timestamp)}</span>
<span>${formatDuration(item.durationMs)}${charsLabel}</span>
</div>
${item.errorMessage ? `<div style="color:#f87171;font-size:0.8rem;">${item.errorMessage}</div>` : ""}
`;
div.addEventListener("click", () => selectRequest(item.id));
requestListEl.appendChild(div);
});
}
async function selectRequest(id) {
selectedId = id;
renderList();
detailContentEl.innerHTML = '<div class="empty-state" style="justify-content:flex-start;align-items:flex-start;">Cargando detalle…</div>';
try {
const response = await fetch(`/requests/${id}`);
if (!response.ok) {
throw new Error("No se pudo cargar el detalle");
}
const detail = await response.json();
renderDetails(detail);
} catch (error) {
detailContentEl.innerHTML = `<div class="empty-state" style="color:#f87171;">${error.message}</div>`;
}
}
function renderDetails(detail) {
detailMethodEl.textContent = detail.method;
detailStatusEl.textContent = detail.status.toUpperCase();
detailStatusEl.className = `pill status ${detail.status}`;
detailTimestampEl.textContent = `${formatDate(detail.timestamp)} · ${formatDuration(detail.durationMs)}`;
const requestPayload = syntaxHighlight(detail.request);
const responsePayload = syntaxHighlight(detail.response ?? detail.error);
const isToolCall = detail.method === "tools/call";
const retryButton = isToolCall
? `<button onclick="retryRequest('${detail.id}')" style="margin-left:auto; padding:0.3rem 0.8rem; background:#3b82f6; border:none; border-radius:4px; color:white; cursor:pointer; font-size:0.85rem;">Reenviar</button>`
: "";
// Inject button into header
const headerActions = document.createElement('div');
headerActions.style.display = 'flex';
headerActions.style.alignItems = 'center';
headerActions.style.gap = '1rem';
headerActions.innerHTML = retryButton;
// Clear previous actions if any (hacky but works for this simple UI)
// We'll just append it to detail-header
detailContentEl.innerHTML = `
<div class="detail-card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem;">
<h2 style="margin:0;">Contexto</h2>
${retryButton}
</div>
<div style="font-size:0.85rem; color:#94a3b8; display:flex; flex-wrap:wrap; gap:1rem;">
<span>Session ID: <code>${detail.sessionId ?? "—"}</code></span>
${detail.toolName ? `<span>Tool: <code>${detail.toolName}</code></span>` : ""}
<span>Chars in: <code>${formatChars(detail.requestChars || 0)}</code></span>
<span>Chars out: <code>${formatChars(detail.responseChars || 0)}</code></span>
<span style="color:#a78bfa;font-weight:600;">Est. tokens: <code>${formatTokens((detail.requestChars || 0) + (detail.responseChars || 0))}</code></span>
</div>
</div>
<div class="detail-card">
<h2>Payload recibido</h2>
<pre><code>${requestPayload}</code></pre>
</div>
<div class="detail-card">
<h2>${detail.status === "error" ? "Error devuelto" : "Respuesta enviada"}</h2>
<pre><code>${responsePayload}</code></pre>
</div>
`;
}
async function retryRequest(id) {
if (!confirm("¿Estás seguro de que quieres reenviar esta petición?")) return;
try {
const btn = document.querySelector(`button[onclick="retryRequest('${id}')"]`);
if (btn) {
btn.disabled = true;
btn.textContent = "Reenviando...";
}
const response = await fetch(`/retry/${id}`, { method: 'POST' });
const result = await response.json();
if (response.ok) {
alert("Petición reenviada con éxito. Se ha generado una nueva entrada en el monitor.");
// The SSE will update the list automatically
} else {
alert("Error al reenviar: " + (result.error || "Error desconocido"));
}
} catch (error) {
alert("Error de red: " + error.message);
} finally {
const btn = document.querySelector(`button[onclick="retryRequest('${id}')"]`);
if (btn) {
btn.disabled = false;
btn.textContent = "Reenviar";
}
}
}
function syntaxHighlight(data) {
if (data == null) {
return "—";
}
const json = JSON.stringify(data, null, 2);
return json.replace(/(&|<|>)/g, (char) => {
const map = { "&": "&amp;", "<": "&lt;", ">": "&gt;" };
return map[char];
});
}
async function bootstrap() {
const [requestsRes, sessionsRes, statsRes] = await Promise.all([
fetch("/requests"),
fetch("/sessions"),
fetch("/stats")
]);
if (requestsRes.ok) {
const { requests } = await requestsRes.json();
requests.forEach((item) => summaries.set(item.id, item));
renderList();
}
if (sessionsRes.ok) {
const data = await sessionsRes.json();
sessions = data.sessions || [];
renderSessions();
}
if (statsRes.ok) {
const data = await statsRes.json();
sessionStats = data.stats || [];
}
}
function initSSE() {
const source = new EventSource("/events");
source.addEventListener("open", () => setConnectionStatus(true));
source.addEventListener("error", () => setConnectionStatus(false));
source.addEventListener("bootstrap", (event) => {
const data = JSON.parse(event.data);
(data.requests || []).forEach((item) => summaries.set(item.id, item));
sessions = data.sessions || [];
sessionStats = data.stats || [];
renderList();
renderSessions();
if (currentTab === 'stats') renderStats();
});
source.addEventListener("summary", (event) => {
const data = JSON.parse(event.data);
summaries.set(data.id, data);
renderList();
if (selectedId === data.id) {
selectRequest(data.id);
}
});
source.addEventListener("sessions", (event) => {
const data = JSON.parse(event.data);
sessions = data.sessions || [];
renderSessions();
});
source.addEventListener("stats", (event) => {
const data = JSON.parse(event.data);
sessionStats = data.stats || [];
if (currentTab === 'stats') renderStats();
});
}
bootstrap();
initSSE();
</script>
</body>
</html>

224
mcp-server/monitor.js Normal file
View File

@@ -0,0 +1,224 @@
import http from "node:http";
import fsPromises from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { MONITOR_PORT, MONITOR_DISABLED } from "./config/index.js";
import { sessionCredentials } from "./auth/credentials.js";
import { activeSessions } from "./httpServer.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const monitorHtmlPath = path.join(__dirname, "monitor.html");
/**
* Get active sessions with their credentials info
*/
function getActiveSessions() {
const sessions = [];
for (const [sessionId, sessionData] of activeSessions.entries()) {
const creds = sessionCredentials.get(sessionId);
sessions.push({
sessionId,
website: creds?.website || null,
hasToken: !!creds?.token,
hasTokenHash: !!creds?.tokenHash,
profileName: creds?.profileName || null,
startTime: sessionData.startTime,
durationMs: Date.now() - sessionData.startTime
});
}
return sessions.sort((a, b) => b.startTime - a.startTime);
}
// SSE clients for real-time updates
export const sseClients = new Set();
/**
* Get the monitor HTML page
*/
async function getMonitorHtml() {
try {
return await fsPromises.readFile(monitorHtmlPath, "utf-8");
} catch (error) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>MCP Monitor</title>
<style>
body { font-family: sans-serif; background: #111827; color: #e5e7eb; margin: 0; padding: 2rem; }
</style>
</head>
<body>
<h1>MCP Monitor</h1>
<p>No se encontró el archivo de interfaz en <code>${monitorHtmlPath}</code>.</p>
</body>
</html>`;
}
}
/**
* Broadcast an SSE event to all connected clients
*/
export function broadcastSse(event, payload) {
const chunk = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
for (const client of sseClients) {
try {
client.write(chunk);
} catch {
sseClients.delete(client);
}
}
}
/**
* Broadcast sessions update to all connected monitor clients
*/
export function broadcastSessionsUpdate() {
broadcastSse("sessions", { sessions: getActiveSessions() });
}
/**
* Start the monitor HTTP server
*/
export function startMonitorServer(requestMonitor, toolHandlers) {
if (MONITOR_DISABLED) {
console.error("MCP monitor UI deshabilitada (MCP_MONITOR_DISABLED=1).");
return null;
}
const monitorServer = http.createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/monitor")) {
const html = await getMonitorHtml();
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(html);
return;
}
if (req.method === "GET" && url.pathname === "/requests") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ requests: requestMonitor.getSummaries() }));
return;
}
if (req.method === "GET" && url.pathname.startsWith("/requests/")) {
const [, , rawId] = url.pathname.split("/");
const entry = requestMonitor.getEntryById(rawId);
if (!entry) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Request not found" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(entry));
return;
}
if (req.method === "GET" && url.pathname === "/sessions") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ sessions: getActiveSessions() }));
return;
}
if (req.method === "GET" && url.pathname === "/stats") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ stats: requestMonitor.getSessionStats() }));
return;
}
if (req.method === "GET" && url.pathname === "/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
});
res.write("event: bootstrap\n");
res.write(`data: ${JSON.stringify({
requests: requestMonitor.getSummaries(),
sessions: getActiveSessions(),
stats: requestMonitor.getSessionStats()
})}\n\n`);
sseClients.add(res);
req.on("close", () => {
sseClients.delete(res);
});
return;
}
if (req.method === "POST" && url.pathname.startsWith("/retry/")) {
const [, , rawId] = url.pathname.split("/");
const entry = requestMonitor.getEntryById(rawId);
if (!entry) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Request not found" }));
return;
}
if (entry.method !== "tools/call") {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Only tool calls can be retried" }));
return;
}
const toolName = entry.request.params.name;
const toolHandler = toolHandlers.get(toolName);
if (!toolHandler) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: `Tool handler for '${toolName}' not found` }));
return;
}
try {
const extra = { sessionId: entry.sessionId };
const retryEntry = requestMonitor.start(entry.method, entry.request, extra);
const result = await toolHandler.handler(entry.request.params.arguments, extra);
requestMonitor.finish(retryEntry, result);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: true, newRequestId: retryEntry.id, result }));
} catch (error) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: error.message }));
}
return;
}
if (req.method === "GET" && url.pathname === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
});
monitorServer.on("error", (error) => {
console.warn(
`[monitor] No se pudo iniciar la UI en el puerto ${MONITOR_PORT}: ${error.message}. Establece MCP_MONITOR_DISABLED=1 para ocultar este aviso.`
);
});
monitorServer.listen(MONITOR_PORT, '0.0.0.0', () => {
console.error(`MCP monitor UI: http://0.0.0.0:${MONITOR_PORT}/monitor`);
});
// Broadcast sessions + stats update every 2 seconds for real-time monitoring
setInterval(() => {
if (sseClients.size > 0) {
broadcastSessionsUpdate();
broadcastSse("stats", { stats: requestMonitor.getSessionStats() });
}
}, 2000);
return monitorServer;
}

3124
mcp-server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
mcp-server/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "acai-code-mcp-server",
"version": "1.0.0",
"description": "MCP Server for Acai Code",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node cluster.js",
"start:single": "node index.js",
"dev": "nodemon index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.26.0",
"@playwright/mcp": "^0.0.68",
"axios": "^1.6.0",
"cheerio": "^1.1.2",
"cors": "^2.8.6",
"dotenv": "^16.3.1",
"express": "^5.2.1",
"jsdom": "^27.2.0",
"redis": "^4.7.0",
"sharp": "^0.33.5",
"zod": "^3.22.4"
},
"devDependencies": {
"nodemon": "^3.1.11"
}
}

View File

@@ -0,0 +1,81 @@
/**
* Prompt: guia-campos-tablas
* Referencia de tipos de campos para crear/editar tablas y sus campos en la base de datos (MySQL/PHP)
*/
export const guiaCamposTablasPrompt = {
name: "guia-campos-tablas",
description: "Referencia de tipos de campos para crear/editar tablas y sus campos en la base de datos (MySQL/PHP)",
args: {},
handler: () => {
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `Al modificar el esquema de la base de datos (crear/editar tablas), utiliza estos tipos de campos.
Presta especial atención a la configuración del campo 'list'.
### Campo LIST (Selector/Relación)
El tipo **'list'** es el más versátil. Define el origen de datos con \`optionsType\`:
1. **Lista Estática (optionsType: 'text')**
- Para opciones fijas simples.
- Formato: \`Valor|Etiqueta\` (una por línea).
- Ejemplo: \`optionsText: "1|Activo\\n0|Inactivo"\`
2. **Relación con Tabla (optionsType: 'table')**
- Para relacionar con otra tabla existente (Foreign Key lógica).
- \`optionsTablename\`: Nombre de la tabla origen (ej: 'cms_categorias').
- \`optionsValueField\`: Campo que se guardará como valor (ej: 'id').
- \`optionsLabelField\`: Campo que se mostrará al usuario (ej: 'nombre').
3. **Consulta SQL (optionsType: 'query')**
- Para relaciones complejas o filtradas.
- \`optionsQuery\`: Tu consulta SQL.
- Usa \`<?php echo $TABLE_PREFIX ?>\` para el prefijo.
- Usa \`<?php echo $ESCAPED_FILTER_VALUE ?>\` para filtros dinámicos.
**Visualización del 'list' (\`listType\`):**
- \`pulldown\`: Select estándar.
- \`radios\`: Botones de radio.
- \`checkboxes\`: Múltiples opciones (array).
- \`pulldownMulti\`: Select múltiple.
---
### Otros Campos Comunes
- **textfield**: Texto corto (VARCHAR). Opciones: \`defaultValue\`, \`fieldWidth\`.
- **textbox**: Texto largo (TEXT). Opciones: \`fieldHeight\`.
- **wysiwyg**: Editor HTML rico. Opciones: \`allowUploads\`.
- **date**: Fecha/Hora. Opciones: \`showTime\` (bool), \`use24HourFormat\` (bool).
- **checkbox**: Booleano. Configura \`checkedValue\` (ej: 1) y \`uncheckedValue\` (ej: 0).
- **upload**: Subida de archivos. Guarda la ruta relativa.
- **multitext**: Estructura JSON para guardar múltiples sub-valores en un solo campo.
- **codigo**: Editor de código fuente.
- **separator**: Solo visual, para organizar el formulario de edición.
**Atributos Globales:**
- \`isRequired\`: Obligatorio.
- \`isUnique\`: Valor único en la tabla.
- \`label\`: Nombre visible para el humano.
- \`name\`: Nombre de la columna en DB (slug).
`
}
}
]
};
}
};
export function registerGuiaCamposTablasPrompt(server) {
server.prompt(
guiaCamposTablasPrompt.name,
guiaCamposTablasPrompt.description,
guiaCamposTablasPrompt.args,
guiaCamposTablasPrompt.handler
);
}

View File

@@ -0,0 +1,13 @@
import { registerGuiaCamposTablasPrompt } from './guiaCamposTablas.js';
/**
* Register all prompts on the MCP server.
*
* Removed duplicates (content already covered by resources):
* - estructuraDatosUpload → covered by guia-registros resource
* - filtrosTwig → covered by guia-twig-filters resource
* - guiaTiposCamposModulos → covered by guia-builder-vars resource
*/
export function registerPrompts(server) {
registerGuiaCamposTablasPrompt(server);
}

View File

@@ -0,0 +1,161 @@
import { EventEmitter } from "events";
function safeClone(value) {
try {
return JSON.parse(
JSON.stringify(value, (_, val) => (typeof val === "bigint" ? val.toString() : val))
);
} catch (error) {
return { error: `Serialization failed: ${error.message}` };
}
}
function formatError(error) {
if (!error) {
return null;
}
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack,
};
}
return {
name: "Error",
message: typeof error === "string" ? error : JSON.stringify(error),
};
}
export class McpRequestMonitor extends EventEmitter {
constructor(options = {}) {
super();
const { maxEntries = 300 } = options;
this.maxEntries = maxEntries;
this.entries = [];
this.nextId = 1;
}
start(method, request, extra = {}) {
const clonedRequest = safeClone(request);
let requestChars = 0;
try { requestChars = JSON.stringify(clonedRequest).length; } catch {}
const entry = {
id: this.nextId++,
method,
timestamp: new Date().toISOString(),
status: "pending",
durationMs: null,
sessionId: extra.sessionId || extra.requestInfo?.headers?.["mcp-session-id"] || null,
requestHeaders: extra.requestInfo?.headers ? { ...extra.requestInfo.headers } : undefined,
request: clonedRequest,
response: null,
error: null,
toolName: method === "tools/call" ? request?.params?.name || null : null,
requestChars,
responseChars: 0,
_startedAt: Date.now(),
};
this.entries.push(entry);
this.trimEntries();
this.emit("summary", this.toSummary(entry));
return entry;
}
finish(entry, response) {
if (!entry) return;
entry.status = "success";
entry.durationMs = Date.now() - entry._startedAt;
entry.response = safeClone(response);
try { entry.responseChars = JSON.stringify(entry.response).length; } catch {}
this.emit("summary", this.toSummary(entry));
}
fail(entry, error) {
if (!entry) return;
entry.status = "error";
entry.durationMs = Date.now() - entry._startedAt;
entry.error = formatError(error);
try { entry.responseChars = JSON.stringify(entry.error).length; } catch {}
this.emit("summary", this.toSummary(entry));
}
getSummaries() {
return this.entries.map((entry) => this.toSummary(entry));
}
getEntryById(id) {
const numericId = typeof id === "number" ? id : Number(id);
const entry = this.entries.find((item) => item.id === numericId);
if (!entry) return null;
const { _startedAt, ...rest } = entry;
return rest;
}
toSummary(entry) {
return {
id: entry.id,
method: entry.method,
timestamp: entry.timestamp,
status: entry.status,
durationMs: entry.durationMs,
sessionId: entry.sessionId,
errorMessage: entry.error?.message || null,
toolName: entry.toolName || null,
requestChars: entry.requestChars || 0,
responseChars: entry.responseChars || 0,
};
}
trimEntries() {
if (this.entries.length <= this.maxEntries) {
return;
}
this.entries.splice(0, this.entries.length - this.maxEntries);
}
getSessionStats() {
const statsMap = {};
for (const entry of this.entries) {
const sid = entry.sessionId;
if (!sid) continue;
if (!statsMap[sid]) {
statsMap[sid] = {
sessionId: sid,
totalRequests: 0,
totalToolCalls: 0,
totalRequestChars: 0,
totalResponseChars: 0,
toolBreakdown: {},
errorCount: 0,
};
}
const stats = statsMap[sid];
stats.totalRequests++;
stats.totalRequestChars += entry.requestChars || 0;
stats.totalResponseChars += entry.responseChars || 0;
if (entry.status === "error") stats.errorCount++;
if (entry.toolName) {
stats.totalToolCalls++;
if (!stats.toolBreakdown[entry.toolName]) {
stats.toolBreakdown[entry.toolName] = { count: 0, requestChars: 0, responseChars: 0, errors: 0, totalDurationMs: 0 };
}
const tb = stats.toolBreakdown[entry.toolName];
tb.count++;
tb.requestChars += entry.requestChars || 0;
tb.responseChars += entry.responseChars || 0;
if (entry.status === "error") tb.errors++;
if (entry.durationMs != null) tb.totalDurationMs += entry.durationMs;
}
}
for (const stats of Object.values(statsMap)) {
for (const tb of Object.values(stats.toolBreakdown)) {
tb.avgDurationMs = tb.count > 0 ? Math.round(tb.totalDurationMs / tb.count) : 0;
delete tb.totalDurationMs;
}
}
return Object.values(statsMap);
}
}

View File

@@ -0,0 +1,8 @@
/**
* Resources registration.
* Documentation has been moved to the scaffold (docs/).
* No MCP resources are registered — Claude reads docs directly from the workspace.
*/
export function registerResources(server) {
// No resources — docs live in the project's docs/ directory
}

100
mcp-server/server.js Normal file
View File

@@ -0,0 +1,100 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { McpRequestMonitor } from "./requestMonitor.js";
import { broadcastSse } from "./monitor.js";
// Tool handlers map for retry functionality (global, shared across sessions)
export const toolHandlers = new Map();
// Registration functions - set by index.js
let _registerPrompts = null;
let _registerTools = null;
let _registerResources = null;
// Shared request monitor instance
let _requestMonitor = null;
/**
* Set the registration functions (called once from index.js)
*/
export function setRegistrationFunctions({ registerPrompts, registerTools, registerResources }) {
_registerPrompts = registerPrompts;
_registerTools = registerTools;
_registerResources = registerResources;
}
/**
* Create and configure the MCP server
* Each session should get its own server instance
*/
export function createMcpServer() {
const server = new McpServer({
name: "acai-code-mcp-server",
version: "1.0.0",
});
// Intercept tool registration to capture handlers for retry/resend from monitor
const originalTool = server.tool.bind(server);
server.tool = (name, ...args) => {
const handler = args[args.length - 1];
toolHandlers.set(name, { handler });
return originalTool(name, ...args);
};
return server;
}
/**
* Create a fully configured server for a new session
* This creates a new McpServer instance with all tools/prompts/resources registered
* IMPORTANT: MCP SDK only supports one transport per server, so each session needs its own server
*/
export function createSessionServer() {
const server = createMcpServer();
// Wrap with request monitoring BEFORE registering tools/prompts
if (_requestMonitor) {
wrapServerWithMonitor(server, _requestMonitor);
}
// Register all tools, prompts, and resources
if (_registerPrompts) _registerPrompts(server);
if (_registerTools) _registerTools(server);
if (_registerResources) _registerResources(server);
return server;
}
/**
* Wrap a server's request handlers with monitoring
*/
function wrapServerWithMonitor(server, monitor) {
const originalSetRequestHandler = server.server.setRequestHandler.bind(server.server);
server.server.setRequestHandler = (schema, handler) => {
const method = schema.shape.method.value;
return originalSetRequestHandler(schema, async (request, extra) => {
const entry = monitor.start(method, request, extra);
try {
const result = await handler(request, extra);
monitor.finish(entry, result);
return result;
} catch (error) {
monitor.fail(entry, error);
throw error;
}
});
};
}
/**
* Create and configure the request monitor
*/
export function createRequestMonitor() {
_requestMonitor = new McpRequestMonitor();
// Broadcast summary updates via SSE
_requestMonitor.on("summary", (summary) => {
broadcastSse("summary", summary);
});
return _requestMonitor;
}

92
mcp-server/stdio.js Normal file
View File

@@ -0,0 +1,92 @@
/**
* Acai Code MCP Server - Stdio Entry Point
*
* Used when Claude Code launches the MCP server directly via .mcp.json.
* Reads credentials from .acai file on each tool call (auto-refresh on token renewal).
*/
import fs from "fs";
import path from "path";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createMcpServer } from "./server.js";
import { registerPrompts } from "./prompts/index.js";
import { registerTools } from "./tools/index.js";
import { registerResources } from "./resources/index.js";
import { sessionCredentials } from "./auth/credentials.js";
// Create server instance
const server = createMcpServer();
registerPrompts(server);
registerTools(server);
registerResources(server);
// Static env vars (web_url and website don't change, token does)
const projectDir = process.env.ACAI_PROJECT_DIR || "";
const website = process.env.ACAI_WEBSITE || "";
const webUrl = process.env.ACAI_WEB_URL || "";
const derivedForgeHost = (() => {
if (!webUrl) return "";
try {
const parsed = new URL(webUrl);
return parsed.hostname.includes("forge.acaisuite.com") ? parsed.host : "";
} catch {
return "";
}
})();
const apiWebUrl = process.env.ACAI_API_WEB_URL || (derivedForgeHost ? "http://web:80/" : webUrl);
const forgeHost = process.env.ACAI_FORGE_HOST || derivedForgeHost;
const acaiFilePath = projectDir ? path.join(projectDir, ".acai") : "";
// Read fresh credentials from .acai file
function readFreshCredentials() {
let token = process.env.ACAI_TOKEN || "";
let tokenHash = process.env.ACAI_TOKEN_HASH || "";
// If .acai file exists, read fresh token from disk (renewed by Python server)
if (acaiFilePath) {
try {
const data = JSON.parse(fs.readFileSync(acaiFilePath, "utf-8"));
if (data.token) token = data.token;
if (data.tokenHash) tokenHash = data.tokenHash;
} catch {
// Fall back to env vars if .acai can't be read
}
}
return {
token,
tokenHash,
website,
web_url: webUrl,
api_web_url: apiWebUrl,
forge_host: forgeHost,
profileName: "stdio",
role: "developer",
};
}
if (!webUrl) {
console.error("[MCP stdio] WARNING: No ACAI_WEB_URL in environment. Tools will fail.");
}
// Set initial credentials
sessionCredentials.set("_default", readFreshCredentials());
// Intercept tool calls to refresh credentials from .acai before each call
const _origSetHandler = server.server.setRequestHandler;
server.server.setRequestHandler = (schema, handler) => {
return _origSetHandler.call(server.server, schema, async (request, extra) => {
// Re-read .acai on every tool call to pick up renewed tokens
const freshCreds = readFreshCredentials();
sessionCredentials.set("_default", freshCreds);
if (extra?.sessionId) {
sessionCredentials.set(extra.sessionId, freshCreds);
}
return handler(request, extra);
});
};
// Connect via stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`[MCP stdio] Connected — ${website}${webUrl} (project: ${projectDir})`);

View 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,
};
}
}
);
}

View 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 });
}
}
);
}

View 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 });
}
}
);
}

View 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 });
}
}
);
}

View 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,
};
}

View 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);
}

View 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 });
}
}
);
}

View 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 });
}
}
);
}

View 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 });
}
}
);
}

View 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.

View 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.

View 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;

View 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;

View 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);
}
};
}

View 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
View 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);
}

View 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 });
}
})
);
}

View 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);
}

View 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 });
}
})
);
}

View 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 });
}
})
);
}

View 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 });
}
})
);
}

View 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 });
}
})
);
}

View 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 });
}
})
);
}

View 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 });
}
})
);
}

View 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);
}

View 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 });
}
})
);
}

View File

@@ -0,0 +1,5 @@
import { registerNavigateBrowserTool } from './navigate.js';
export function registerNavigationTools(server) {
registerNavigateBrowserTool(server);
}

View 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 });
}
})
);
}

View 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 };

View File

@@ -0,0 +1,5 @@
import { registerOrchestrateTool } from "./orchestrate.js";
export function registerOrchestratorTools(server) {
registerOrchestrateTool(server);
}

View 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
};
}
}
);
}

View 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"
]
};

View 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"
]
};

View 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"
]
};

View 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: []
};

View 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,
}));
}

View 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: []
};

View 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"
]
};

View 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"
]
};

View 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: []
};

View File

@@ -0,0 +1,5 @@
import { registerSaveProjectStylesTool } from "./saveStyles.js";
export function registerProjectTools(server) {
registerSaveProjectStylesTool(server);
}

View 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", {});
}
})
);
}

View 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 });
}
})
);
}

View 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) });
}
})
);
}

View 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 });
}
})
);
}

View 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 });
}
})
);
}

View 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 });
}
})
);
}

View 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);
}

View 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 });
}
})
);
}

View 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 });
}
})
);
}

View 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 });
}
})
);
}

View 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 });
}
})
);
}

View 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 });
}
})
);
}

View 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 });
}
})
);
}

View File

@@ -0,0 +1,5 @@
import { registerRecoverGitTools } from './rollback.js';
export function registerRemoteGitTools(server) {
registerRecoverGitTools(server);
}

View 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');
}
})
);
}

View 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 });
}
})
);
}

View 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 });
}
})
);
}

View 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 });
}
})
);
}

View 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);
}

View 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');
}
})
);
}

View 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 });
}
})
);
}

View File

@@ -0,0 +1,55 @@
const SAFE_INTERNAL_HOSTS = new Set(["web", "acai-web", "localhost", "127.0.0.1"]);
function parseUrl(url, fieldName, context) {
try {
return new URL(url);
} catch {
throw new Error(`[${context}] Invalid ${fieldName}: ${url || "<empty>"}`);
}
}
export function assertSafeCmsTarget(target, context = "cms") {
const publicUrl = typeof target === "string" ? target : (target?.web_url || "");
const apiUrl = typeof target === "string" ? target : (target?.api_web_url || "");
if (!apiUrl) {
throw new Error(
`[${context}] ACAI_API_WEB_URL is required. Refusing to use ACAI_WEB_URL directly for CMS requests.`
);
}
const parsedApiUrl = parseUrl(apiUrl, "ACAI_API_WEB_URL", context);
if (!SAFE_INTERNAL_HOSTS.has(parsedApiUrl.hostname)) {
throw new Error(
`[${context}] Unsafe ACAI_API_WEB_URL host "${parsedApiUrl.hostname}". ` +
`Only approved local hosts are allowed: ${Array.from(SAFE_INTERNAL_HOSTS).join(", ")}.`
);
}
if (!["http:", "https:"].includes(parsedApiUrl.protocol)) {
throw new Error(
`[${context}] Unsafe ACAI_API_WEB_URL protocol "${parsedApiUrl.protocol}".`
);
}
if (publicUrl) {
const parsedPublicUrl = parseUrl(publicUrl, "ACAI_WEB_URL", context);
const publicIsSafeInternal = SAFE_INTERNAL_HOSTS.has(parsedPublicUrl.hostname);
if (!publicIsSafeInternal && parsedPublicUrl.host === parsedApiUrl.host) {
throw new Error(
`[${context}] ACAI_API_WEB_URL resolves to the same public host as ACAI_WEB_URL (${parsedApiUrl.host}).`
);
}
}
return {
publicUrl,
apiUrl,
forgeHost: typeof target === "string" ? null : (target?.forge_host || null),
};
}
export function isSafeInternalHost(hostname) {
return SAFE_INTERNAL_HOSTS.has(hostname);
}

View File

@@ -0,0 +1,195 @@
/**
* Field and schema manipulation helpers
*/
export const LIST_OPTION_ALIAS_KEYS = ["options", "optionsList", "optionList", "choices", "values", "items"];
export const optionEntryToLine = (entry) => {
if (entry == null) {
return null;
}
if (typeof entry === "string") {
const trimmed = entry.trim();
if (!trimmed) return null;
if (trimmed.includes("|")) return trimmed.replace(/\r/g, "");
return `${trimmed}|${trimmed}`;
}
if (Array.isArray(entry)) {
const [valueRaw, labelRaw] = entry;
const value = valueRaw ?? labelRaw;
const label = labelRaw ?? valueRaw;
if (value == null && label == null) return null;
const valueStr = `${value ?? ""}`.trim();
const labelStr = `${label ?? valueStr}`.trim();
if (!valueStr) return null;
return `${valueStr}|${labelStr || valueStr}`;
}
if (typeof entry === "object") {
const value =
entry.value ??
entry.id ??
entry.key ??
entry.slug ??
entry.code ??
entry.name ??
entry.label ??
entry.text;
const label = entry.label ?? entry.text ?? entry.name ?? entry.title ?? value;
if (value == null && label == null) return null;
const valueStr = `${value ?? ""}`.trim();
const labelStr = `${label ?? valueStr}`.trim();
if (!valueStr) return null;
return `${valueStr}|${labelStr || valueStr}`;
}
return null;
};
export const buildOptionsTextFromInput = (input) => {
if (input == null) {
return "";
}
if (Array.isArray(input)) {
return input.map(optionEntryToLine).filter(Boolean).join("\n");
}
if (typeof input === "object") {
return Object.entries(input)
.map(([value, label]) => optionEntryToLine({ value, label }))
.filter(Boolean)
.join("\n");
}
if (typeof input === "string") {
const trimmed = input.trim();
if (!trimmed) {
return "";
}
if (trimmed.includes("|")) {
return trimmed
.replace(/\r/g, "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.join("\n");
}
const hasNewLines = /\r|\n/.test(trimmed);
const separator = hasNewLines ? /\r?\n/ : /,/;
return trimmed
.split(separator)
.map((token) => token.trim())
.filter(Boolean)
.map((token) => `${token}|${token}`)
.join("\n");
}
return "";
};
export const normalizeListFieldDefinition = (field = {}) => {
if (!field || field.type !== "list") {
return field;
}
if (!field.listType) {
field.listType = "pulldown";
}
if (!field.optionsType) {
field.optionsType = "text";
}
if (field.optionsType === "text") {
let source = field.optionsText;
if (Array.isArray(source) || (source && typeof source === "object" && !Array.isArray(source))) {
field.optionsText = buildOptionsTextFromInput(source);
} else {
let aliasValue;
for (const aliasKey of LIST_OPTION_ALIAS_KEYS) {
if (field[aliasKey] != null) {
aliasValue = field[aliasKey];
delete field[aliasKey];
break;
}
}
if (aliasValue != null) {
field.optionsText = buildOptionsTextFromInput(aliasValue);
} else if (typeof field.optionsText === "string") {
field.optionsText = buildOptionsTextFromInput(field.optionsText);
} else {
field.optionsText = field.optionsText ?? "";
}
}
} else {
// Ensure plain text payload for non-text option sources.
if (field.optionsText && typeof field.optionsText !== "string") {
field.optionsText = "";
}
for (const aliasKey of LIST_OPTION_ALIAS_KEYS) {
if (field[aliasKey] != null) {
delete field[aliasKey];
}
}
}
return field;
};
export const normalizeSchemaForSave = (schema = {}) => {
if (!schema || typeof schema !== "object") {
return schema;
}
if (schema.schema && typeof schema.schema === "object") {
const normalized = {};
for (const [fieldName, fieldDefinition] of Object.entries(schema.schema)) {
if (!fieldDefinition || typeof fieldDefinition !== "object") {
normalized[fieldName] = fieldDefinition;
continue;
}
const clonedDefinition = { ...fieldDefinition };
if (clonedDefinition.type === "list") {
normalized[fieldName] = normalizeListFieldDefinition(clonedDefinition);
} else {
normalized[fieldName] = clonedDefinition;
}
}
schema.schema = normalized;
}
return schema;
};
export const mergeTableSchemas = (currentTable, incomingSchema = {}) => {
const merged = {
...currentTable,
schema: { ...(currentTable?.schema || {}) },
schemaInfo: { ...(currentTable?.schemaInfo || {}) },
};
if (!incomingSchema || typeof incomingSchema !== "object") {
return merged;
}
for (const [key, value] of Object.entries(incomingSchema)) {
if (key === "schema" && value && typeof value === "object") {
merged.schema = { ...(currentTable?.schema || {}), ...value };
} else if (key === "schemaInfo" && value && typeof value === "object") {
merged.schemaInfo = { ...(currentTable?.schemaInfo || {}), ...value };
} else {
merged[key] = value;
}
}
return merged;
};

View File

@@ -0,0 +1,60 @@
/**
* Utility functions shared across the MCP server
*/
export const encodeBase64 = (value) => Buffer.from(value, "utf-8").toString("base64");
export const cleanDomainValue = (domain) => {
if (!domain) {
return null;
}
return domain.replace(/^https?:\/\//i, "").replace(/\/$/, "").toLowerCase();
};
export const mapDomainsForOutput = (domains = []) =>
domains.map((item) => ({
num: item.num,
domain: item.domain,
label: item.domain,
isMulti: item.is_multi_child === "1" || item.multiSite === "1",
}));
export const extractTokenHash = (payload) => {
if (!payload || typeof payload !== "object") {
return null;
}
if (payload.tokenHash) {
return payload.tokenHash;
}
if (payload.token_hash) {
return payload.token_hash;
}
if (payload.data && typeof payload.data === "object") {
return extractTokenHash(payload.data);
}
return null;
};
export const pickDomain = (domains = [], domainInput, domainNumInput) => {
if (!domains.length) {
return null;
}
if (domainNumInput != null) {
const domainNum = String(domainNumInput).trim();
const domainByNum = domains.find((entry) => String(entry.num).trim() === domainNum);
if (domainByNum) {
return domainByNum;
}
}
if (!domainInput) {
return null;
}
const normalizedDomain = cleanDomainValue(domainInput);
return domains.find((entry) => cleanDomainValue(entry.domain) === normalizedDomain);
};
export const getSessionKey = (username, password) => `${username}::${password}`;

View File

@@ -0,0 +1,4 @@
export * from './helpers.js';
export * from './fieldHelpers.js';

View File

@@ -0,0 +1,436 @@
// Helper function for base64 encoding (works in both browser and Node.js)
const btoa = typeof window !== 'undefined' ? btoa : (str) => Buffer.from(str).toString('base64');
export const builderData = {
"data-field-type" : {
type:"ATTRIBUTE",
description:`Determina que el elemento es editable desde el Builder para los clientes. Se puede añadir un elemento multi que da la posibilidad de añadir distintos bloques a los clientes. Dentro de cada multi se podrán poner campos de edición
<br><br>Nota : Los componentes de estos ejemplos pueden variar dependiendo del analizador léxico utilizado.`,
example : `<div data-field-type="textfield" data-field-label="Label" >Elemento editable</div>
<div data-field-type="headfield" data-field-label="Titulo" >Elemento editable</div>
<div data-field-type="link" data-field-label="Enlace">Elemento editable</div>
<div data-field-type="textbox" data-field-label="Texto Largo">Elemento editable</div>
<div data-field-type="wysiwyg" data-field-label="Texto Largo enriquecido">Elemento editable</div>
<img data-field-type="upload" data-field-rand="true" data-field-label="Image" data-field-info1="titulo" data-field-width="ancho maximo en pixeles (opcional. Ej 1400)"\>
<div data-field-type="uploadBackground" data-field-label="Imagen de fondo">Elemento editable</div>
<div data-field-type="list" data-field-label="pruebas" data-list-options="opcion1,opcion2,|option3,4|opcion 4" ></div>
<div data-field-type="list" data-field-label="pruebas" data-list-table="nombredeTabla" data-list-value="campoValor" data-list-label="campoLabel">
{{record.name}}
</div>
<div data-field-type="list" data-field-label="Apartados" data-list-query="select num,name from cms_otros_contenidos" data-list-multi>
<div v-for="contenido in otros_contenidos" v-where="num=$record">{{contenido.name}} </div>
</div>
Si se desea que sea multi añadir el atributo data-list-multi
<div data-field-type="list" data-list-multi></div>
<div data-field-type="uploadMulti" data-field-label="Imagenes" data-field-info1="titulo" >
<img src="{{uploadMulti.urlPath | imagec(700)}}"\>
</div>
<ul>
NOTA : El Multi debe tener un nodo padre ( sin hermanos ).
<li data-field-type="multiv2" data-field-label="Records">
<div data-field-type="textfield" data-field-label="Label" >Elemento editable</div>
<div data-field-type="textbox" data-field-label="Texto Largo">Elemento editable</div>
<img data-field-type="upload" data-field-info1="titulo" data-field-label="Image" data-field-width="ancho maximo en pixeles (opcional. Ej 1400)"\>
</li>
</ul>
`,
shortcode : ``,
directLinks : {
TextField : `<p c-if="label" data-field-type="textfield" data-field-label="Label" >Elemento editable</p>`,
HeadField : `<p c-if="titulo" data-field-type="headfield" data-field-label="Titulo" >Elemento editable</p>`,
'List (Options)' : `<div data-field-type="list" data-field-label="pruebas" data-list-options="opcion1,opcion2,|option3,4|opcion 4" ></div>`,
'List (Table)' : `<div data-field-type="list" data-field-label="pruebas" data-list-table="nombredeTabla" data-list-value="campoValor" data-list-label="campoLabel">
{{record.name}}
</div>`,
Link : `<a c-if="enlace_anchor" data-field-type="link" data-field-label="Enlace">Elemento editable</a>`,
TextBox : `<div c-if="textolargo" data-field-type="textbox" data-field-label="Texto Largo">Elemento editable</div>`,
Wysiwyg : `<div c-if="textolargoenriquecido" class="wysiwyg" data-field-type="wysiwyg" data-field-label="Texto Largo enriquecido">Elemento editable</div>`,
Upload : `<div c-if="imagen.0.urlPath" class="p-1/6 relative"><img class="absolute top-0 left-0 w-full h-full object-cover object-center lazyload" data-field-type="upload" data-field-label="Imagen" data-lazy="true" data-field-info1="titulo" data-field-width="1400" alt=""\></div>`,
UploadBackground : `<div c-if="imagendefondo.0.urlPath" class="bg-cover bg-center bg-no-repeat" data-field-type="uploadBackground" data-field-info1="titulo" data-field-label="Imagen de fondo">Elemento editable</div>`,
UploadMulti : `<div c-if="imagenes" data-field-type="uploadMulti" data-field-label="Imagenes" data-field-info1="titulo" >
<img src="{{uploadMulti.urlPath | imagec(700)}}"\>
</div>`,
Multi : `<ul>
<li data-field-type="multiv2" data-field-label="Records">
<div data-field-type="textfield" data-field-label="Label">Elemento editable</div>
<div data-field-type="textbox" data-field-label="Texto Largo">Elemento editable</div>
<div class="p-1/6 relative"><img class="absolute top-0 left-0 w-full h-full object-cover object-center lazyload" data-field-type="upload" data-field-label="Imagen" data-lazy="true" data-field-info1="titulo" data-field-width="1400" alt=""\></div>
</li>
</ul>`,
},
shortcuts: {
'C-Form' : `<c-form tableName="" sendToClient="string campo que se usará como cliente" sendTo="string correos separados por coma" honeypot="true" captcha="true" mailRecord="['correos','SOLICITUD']" class="">
</c-form>`,
Hook: `<hook endpoint="/hooks/nombre_del_hook/" result="almacen_del_resultado" :variable="variable" :variable_string="'mi string'"></hook>`,
Dump: `<pre style="display:none">{{dump(thisrecord)}}</pre>`,
TextoGeneral: `{{'' | translate}}`
},
replace : (el,prefixVar) => {
// ACAI ANALYZER
let attr = el.getAttribute("data-field-type");
if (!attr) return el.outerHTML;
el.removeAttribute("data-field-type");
let label = el.getAttribute("data-field-label");
if (!label) {
label = "Untitled " + new Date().getTime();
}else{
el.removeAttribute("data-field-label");
}
let width = el.getAttribute("data-field-width");
if (!width){
width = 1600;
}else{
el.removeAttribute("data-field-width");
}
let infos = [];
for (let i=1;i<5;i++){
var info = el.getAttribute("data-field-info"+i);
if (info){
infos.push(info);
el.removeAttribute("data-field-info"+i);
}
}
const field = prefixVar ? `${prefixVar}["${appParser.cleanString(label)}"]` : `$${appParser.cleanString(label)}`;
const field_anchor = prefixVar ? `${prefixVar}["${appParser.cleanString(label)}_anchor"]` : `$${appParser.cleanString(label)}_anchor`;
const field_tag = prefixVar ? `${prefixVar}["${appParser.cleanString(label)}_tag"]` : `$${appParser.cleanString(label)}_tag`;
let rand = el.getAttribute("data-field-rand");
if (!rand){
rand = ``;
}else{
rand = `<? shuffle(${field});?>`;
}
switch(attr){
case "multiv2":
let php1 = '|*' + btoa(`<? foreach(${field} as $index => $record){ ?>`) + '*|';
let php2 = '|*' + btoa(`<? } ?>`) + '*|';
let string = `${php1}${appParser.parseComponents(el.outerHTML,`$record`)}${php2}`;
el.outerHTML = string;
break;
case "link":
if (el.tagName!='A'){
let php1 = '|*' + btoa(`<? echo ${field}; ?>`) + '*|';
let php2 = '|*' + btoa(`<? echo ${field_anchor}; ?>`) + '*|';
el.innerHTML = el.hasChildNodes() && Array.from(el.childNodes).filter(node => node.nodeType !== 3).length ? `<a href='${php1}'>${appParser.parseComponents(el.innerHTML,prefixVar)}</a>` : `<a href='${php1}'>${php2}</a>`;
}else{
el.setAttribute('href','|*' + btoa(`<? echo @${field}; ?>`) + '*|');
el.innerHTML = el.hasChildNodes() && Array.from(el.childNodes).filter(node => node.nodeType !== 3).length ? appParser.parseComponents(el.innerHTML,prefixVar) : '|*' + btoa(`<? echo @${field_anchor};?>`) + '*|';
}
break;
case "uploadMulti":
let php1up = '|*' + btoa(`${rand}<? foreach(${field} as $index => $uploadMulti){ ?>`) + '*|';
let php2up = '|*' + btoa(`<? } ?>`) + '*|';
let resultVariables = appParser.parseVariables2(el.outerHTML);
let preStringVars = resultVariables[0];
let stringVars = resultVariables[1];
let stringup = `${php1up}${preStringVars}${stringVars}${php2up}`;
el.outerHTML = stringup;
break;
case "list":
const isTable = el.hasAttribute("data-list-table");
const tableSelect = el.getAttribute("data-list-table");
const valueSelect = el.getAttribute("data-list-value");
const labelSelect = el.getAttribute("data-list-label");
const querySelect = el.getAttribute("data-list-query");
el.removeAttribute("data-list-table");
el.removeAttribute("data-list-options");
el.removeAttribute("data-list-value");
el.removeAttribute("data-list-label");
el.removeAttribute("data-list-multi");
el.removeAttribute("data-list-query");
let php1li = '|*' + btoa(`<? ${field} = array_filter(explode("\t",${field}));if (isset($record)) $auxRecord = $record; foreach(${field} as $index => $record){ ?>`) + '*|';
if (isTable) php1li += '|*' + btoa(`<? $schema = loadSchema("${tableSelect}"); if (@$schema) {$record = @dame_registros("${tableSelect}","${valueSelect}='$record'","num desc",1)[0];}else{ global $TABLE_PREFIX; $record = @mysql_query_fetch_all_assoc("SELECT * FROM ".$TABLE_PREFIX."${tableSelect} WHERE ${valueSelect}='$record' LIMIT 1")[0]; } if (!$record) continue;?>`) + '*|';
let php2li = '|*' + btoa(`<? }
if (isset($auxRecord)) $record = $auxRecord;?>`) + '*|';
let stringli = `${php1li}${appParser.parseComponents(el.outerHTML,`$record`)}${php2li}`;
el.outerHTML = stringli;
break;
case "upload":
if (el.hasAttribute("src")){
el.setAttribute('src','|*' + btoa(`${rand}<? echo CustomCode::imagec(${width},${field}[0]['urlPath']);?>`) + '*|');
}else{
// let classString = el.getAttribute("class");
// let styleString = el.getAttribute("style");
// let altString = el.getAttribute("alt");
var srcAttr = "src";
var output = "";
const attrs = el.attributes;
for(var i = attrs.length - 1; i >= 0; i--) {
if (attrs[i].name.toLowerCase() == "src") continue;
if (attrs[i].name.toLowerCase() == "data-lazy") { srcAttr="data-src"; continue;}
output += `${attrs[i].name}="${attrs[i].value}" `;
}
console.log({output:output});
let php = '|*' + btoa(`${rand}<? echo CustomCode::imagec(${width},${field}[0]['urlPath']); ?>`) + '*|';
el.outerHTML = `<img ${srcAttr}='${php}' ${output}>`;
}
break;
case "uploadBackground":
let php3 = '|*' + btoa(`${rand}<? echo CustomCode::imagec(${width},${field}[0]['urlPath']); ?>`) + '*|';
el.setAttribute('style',`background-image:url('${php3}')`);
break;
case "headfield":
let outputh = "";
const attrs = el.attributes;
for(var i = attrs.length - 1; i >= 0; i--) {
outputh += `${attrs[i].name}="${attrs[i].value}" `;
}
let phph1 = '|*' + btoa(`<? echo ${field}; ?>`) + '*|';
let phph2 = '|*' + btoa(`<? echo '<'.${field_tag}.' ${outputh}>'; ?>`) + '*|';
let phph3 = '|*' + btoa(`<? echo '</'.${field_tag}.'>'; ?>`) + '*|';
el.outerHTML = `<span>${phph2} ${phph1} ${phph3}</span>`;
break;
default:
//el.classList.add(`wed_${field.replace(/\$/g,"")}:${attr}`);
el.innerHTML = '|*' + btoa(`<? echo @${field} ? nl2br(${field}) : '${el.innerHTML}';?>`) + '*|';
}
return el;
},
replace2 : (el,prefixVar) => {
// TWIG ANALYZER
let attr = el.getAttribute("data-field-type");
if (!attr) return el.outerHTML;
el.removeAttribute("data-field-type");
let label = el.getAttribute("data-field-label");
if (!label) {
label = "Untitled " + new Date().getTime();
}else{
el.removeAttribute("data-field-label");
}
let width = el.getAttribute("data-field-width");
if (!width){
width = 1600;
}else{
el.removeAttribute("data-field-width");
}
let infos = [];
for (let i=1;i<5;i++){
var info = el.getAttribute("data-field-info"+i);
if (info){
infos.push(info);
el.removeAttribute("data-field-info"+i);
}
}
const field = prefixVar ? `${prefixVar}.${appParser.cleanString(label)}` : `${appParser.cleanString(label)}`;
const field_anchor = prefixVar ? `${prefixVar}.${appParser.cleanString(label)}_anchor` : `${appParser.cleanString(label)}_anchor`;
const field_tag = prefixVar ? `${prefixVar}.${appParser.cleanString(label)}_tag` : `${appParser.cleanString(label)}_tag`;
let rand = el.getAttribute("data-field-rand");
if (!rand){
rand = ``;
}else{
rand = `{% ${field} = ${field} | shuffle %}\n`;
}
switch(attr){
case "multiv2":
let php1 = `\n{% for record in ${field} %} {% set index = loop.index0 %}\n`;
let php2 = `\n{% endfor %}\n`;
let string = `${php1}${appParser.parseComponents(el.outerHTML,`record`,2)}${php2}`;
el.outerHTML = string;
break;
case "link":
if (el.tagName!='A'){
let php1 = `{{ ${field} }}`;
let php2 = `{{ ${field_anchor} }}`;
el.innerHTML = el.hasChildNodes() && Array.from(el.childNodes).filter(node => node.nodeType !== 3).length ? `<a href='${php1}'>${appParser.parseComponents(el.innerHTML,prefixVar)}</a>` : `<a href='${php1}'>${php2}</a>`;
}else{
el.setAttribute('href',`{{ ${field} }}`);
el.innerHTML = el.hasChildNodes() && Array.from(el.childNodes).filter(node => node.nodeType !== 3).length ? appParser.parseComponents(el.innerHTML,prefixVar) : `{{ ${field_anchor} }}`;
}
break;
case "uploadMulti":
let php1up = `\n ${rand} \n {% for uploadMulti in ${field} %} \n {% set index = loop.index0 %} \n`;
let php2up = `\n {% endfor %} \n `;
let stringup = `${php1up}${el.outerHTML}${php2up}`;
el.outerHTML = stringup;
break;
case "list":
const isTable = el.hasAttribute("data-list-table");
const tableSelect = el.getAttribute("data-list-table");
const valueSelect = el.getAttribute("data-list-value");
const labelSelect = el.getAttribute("data-list-label");
const querySelect = el.getAttribute("data-list-query");
el.removeAttribute("data-list-table");
el.removeAttribute("data-list-options");
el.removeAttribute("data-list-value");
el.removeAttribute("data-list-label");
el.removeAttribute("data-list-multi");
el.removeAttribute("data-list-query");
let php1li = `\n
{% set list_values = ${field} | trim | split("\t") %} \n
`;
php1li += `\n
{% if record %} \n
{% set auxRecord = record %} \n
{% endif %} \n
`;
php1li += `\n
{% for record in list_values %} \n
{% set index = loop.index0 %} \n
`;
if (isTable) {
php1li += `\n
{% if '${tableSelect}' | loadSchema %} \n
{% set record = '${tableSelect}' | get([{'column':'${valueSelect}','operator':'=','value':record}]) %} \n
{% set record = record.0 %} \n
{% else %} \n
{% set record = 'cms_${tableSelect}' | get([{'column':'${valueSelect}','operator':'=','value':record}],'num desc',1,{'ignoreSchema':true}) %}
{% set record = record.0 %}
{% endif %}
`;
}
php2li = `\n
{% endfor %}\n
{% if auxRecord %} {% set record = auxRecord %} {% endif %}
`;
/*let php1li = '|*' + btoa(`<? ${field} = array_filter(exp lode("\t",${field}));if (isset($record)) $auxRecord = $record; foreach(${field} as $index => $record){ ?>`) + '*|';
if (isTable) php1li += '|*' + btoa(`<? $schema = loadSchema("${tableSelect}"); if (@$schema) {$record = @dame_registros("${tableSelect}","${valueSelect}='$record'","num desc",1)[0];}else{ global $TABLE_PREFIX; $record = @mysql_query_fetch_all_assoc("SELECT * FROM ".$TABLE_PREFIX."${tableSelect} WHERE ${valueSelect}='$record' LIMIT 1")[0]; } if (!$record) continue;?>`) + '*|';
let php2li = '|*' + btoa(`<? }
if (isset($auxRecord)) $record = $auxRecord;?>`) + '*|';*/
let stringli = `${php1li}${appParser.parseComponents(el.outerHTML,`record`,2)}${php2li}`;
el.outerHTML = stringli;
break;
case "upload":
if (el.hasAttribute("src")){
el.setAttribute('src',`${rand}{{ ${field}.0.urlPath | imagec(${width}) }}`);
}else{
// let classString = el.getAttribute("class");
// let styleString = el.getAttribute("style");
// let altString = el.getAttribute("alt");
var srcAttr = "src";
var output = "";
const attrs = el.attributes;
for(var i = attrs.length - 1; i >= 0; i--) {
if (attrs[i].name.toLowerCase() == "src") continue;
if (attrs[i].name.toLowerCase() == "data-lazy") { srcAttr="data-src"; continue;}
output += `${attrs[i].name}="${attrs[i].value}" `;
}
console.log({output:output});
let php = `${rand}{{ ${field}.0.urlPath | imagec(${width}) }}`;
el.outerHTML = `<img ${srcAttr}='${php}' ${output}>`;
}
break;
case "uploadBackground":
let php3 = `${rand}{{ ${field}.0.urlPath | imagec(${width}) }}`;
el.setAttribute('style',`background-image:url('${php3}')`);
break;
case "textbox":
//el.classList.add(`wed_${field.replace(/\$/g,"")}:${attr}`);
var filter = "nl2br";
var expre = new RegExp("<(\\S*?)[^>]*>.*?</\\1>|<.*?/>");
el.innerHTML = `
{% if ${field} %} \n
{% if ${field} | isHTML %}
{{ ${field} | raw }} \n
{% else %}
{{ ${field} | nl2br }} \n
{% endif %}
{% else %} \n
{{ "${el.innerHTML.replace(/\x22/g, '\\\x22')}" | nl2br }} \n
{% endif %}
`;
break;
case "headfield":
let outputh = "";
const attrs = el.attributes;
for(var i = attrs.length - 1; i >= 0; i--) {
outputh += `${attrs[i].name}="${attrs[i].value}" `;
}
let phph1 = `{{ ${field} }}`;
let phph2 = `{{ '<' ~ (${field_tag} ? ${field_tag} : 'P') ~ '${outputh}>' }}`;
let phph3 = `{{ '< /' ~ (${field_tag} ? ${field_tag} : 'P') ~ '>' }}`;
// ESTA CODIFICADO EN BASE64 PORQUE ACAI LO CONVIERTE A COMENTARIO POR EL ANALIZADOR LEXICO DE ACAI QUE YA NO DEBE DE ESTAR
let php4 = ``;
let phph4 = ``;
if (prefixVar){
// {% set record = record|merge({'nombre_tag':record.nombre_tag ?: 'P'}) %}
phph4 = `
{% set ${prefixVar} = ${prefixVar}|merge({'${appParser.cleanString(label)}_tag': ${field_tag} ?: 'P'}) %}
{{ ('<' ~ ${field_tag} ~ ' ${outputh}>' ~ (${field} ? ${field} : '${el.innerHTML.replace(/\x22/g, '\\\x22')}') ~ ('PC8=' | base64_decode) ~ ${field_tag} ~ '>') | raw }}
`;
}else{
phph4 = `
{% set ${field_tag} = ${field_tag} ?: 'P' %}
{{ ('<' ~ ${field_tag} ~ ' ${outputh}>' ~ (${field} ? ${field} : '${el.innerHTML.replace(/\x22/g, '\\\x22')}') ~ ('PC8=' | base64_decode) ~ ${field_tag} ~ '>') | raw }}
`;
}
el.outerHTML = `${phph4}`;
break;
default:
//el.classList.add(`wed_${field.replace(/\$/g,"")}:${attr}`);
el.innerHTML = `
{% if ${field} %} \n
{{ ${field} | raw }} \n
{% else %} \n
{{ "${el.innerHTML.replace(/\x22/g, '\\\x22')}" | raw }} \n
{% endif %}
`;
}
return el;
}
}
};

View File

@@ -0,0 +1,33 @@
import { parseComponents as remoteParseComponents, generateBuilderVars as remoteGenerateBuilderVars } from "./remoteParser.js";
export class ModuleParser {
/**
* Parse components (c-if, c-for, c-else, c-hidden) and module tags
* Converts Acai syntax to Twig syntax
* Uses the remote appParser to ensure consistency with frontend
* @param {string} html - The HTML content to parse
* @param {string[]} moduleIds - Optional list of module IDs to recognize as tags
* @param {string} prefixVar - Optional prefix for variables (used internally)
* @param {boolean} skipBuilderData - Optional flag to skip extractBuilderData (to avoid recursion)
* @returns {Promise<string>} - The parsed HTML with Twig syntax
*/
static async parseComponents(html, moduleIds = [], listTables = [], prefixVar = "", skipBuilderData = false) {
if (!html) return html;
// Use the remote appParser (same as frontend)
return await remoteParseComponents(html, moduleIds, listTables, prefixVar, skipBuilderData);
}
/**
* Generate builder variables from code
* Uses the remote appParser to ensure consistency with frontend
* @param {string} code - The HTML code to analyze
* @param {object} previousSchema - Optional previous schema for field name mapping
* @returns {Promise<{codeParsed: string, codeVars: object}>} - Parsed code and variables
*/
static async generateBuilderVars(code, previousSchema = null) {
// Use the remote appParser (same as frontend)
return await remoteGenerateBuilderVars(code, previousSchema);
}
}

View File

@@ -0,0 +1,155 @@
import { JSDOM } from 'jsdom';
import axios from 'axios';
import vm from 'vm';
// Cache para los scripts y appParser
let appParserCache = null;
let windowCache = null;
let scriptsCache = null;
/**
* Descarga y ejecuta los scripts remotos necesarios para appParser
* Igual que en el frontend (src/main.js)
*/
async function loadRemoteParser() {
if (appParserCache) {
return { appParser: appParserCache, window: windowCache };
}
const scripts = [
"https://cms.cocosolution.com/lib/plugins/builder_saas/js/lexer.js",
"https://cms.cocosolution.com/lib/plugins/builder_saas/js/mixins/vuecomponents.js",
"https://cms.cocosolution.com/lib/plugins/builder_saas/js/mixins/builderdata.js",
"https://cms.cocosolution.com/lib/plugins/builder_saas/js/mixins/filters.js",
"https://cms.cocosolution.com/lib/plugins/builder_saas/js/parseDocument.js",
];
// Crear un contexto jsdom
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
runScripts: "dangerously",
resources: "usable"
});
const window = dom.window;
const document = window.document;
// Mock de objetos necesarios que pueden no estar en jsdom
if (!window.btoa) {
window.btoa = (str) => Buffer.from(str).toString('base64');
}
if (!window.atob) {
window.atob = (str) => Buffer.from(str, 'base64').toString();
}
// Asegurar que DOMParser esté disponible (jsdom lo tiene en window)
// Pero también lo necesitamos como variable global para los scripts
const DOMParser = window.DOMParser;
// Mock de window.bus (puede que no sea necesario para el parseo)
if (!window.bus) {
window.bus = {
$emit: () => {},
$on: () => {},
$off: () => {}
};
}
// Crear contexto VM con todas las referencias necesarias
// Los scripts remotos esperan que window, document, DOMParser, etc. estén disponibles globalmente
const context = vm.createContext({
window: window,
document: document,
DOMParser: DOMParser, // Añadir DOMParser como variable global
console: console,
Buffer: Buffer,
setTimeout: setTimeout,
setInterval: setInterval,
clearTimeout: clearTimeout,
clearInterval: clearInterval,
// Añadir todas las propiedades globales necesarias
...global,
// Asegurar que las referencias estén disponibles también como variables globales
global: global,
process: process
});
// Descargar y ejecutar cada script
for (const scriptUrl of scripts) {
try {
console.log(`Descargando script: ${scriptUrl}`);
const response = await axios.get(scriptUrl, {
timeout: 10000 // 10 segundos de timeout
});
const scriptContent = response.data;
// Ejecutar el script en el contexto VM
// Los scripts pueden usar 'window', 'document', etc. directamente
vm.runInContext(scriptContent, context);
} catch (error) {
console.error(`Error cargando script ${scriptUrl}:`, error.message);
// Si falla un script crítico, lanzar error
if (scriptUrl.includes('parseDocument.js')) {
throw new Error(`Error crítico cargando parseDocument.js: ${error.message}`);
}
// Continuar con los demás scripts para los no críticos
}
}
// Verificar que appParser esté disponible
if (!window.appParser) {
throw new Error('appParser no se cargó correctamente desde los scripts remotos');
}
appParserCache = window.appParser;
windowCache = window;
scriptsCache = scripts;
return { appParser: appParserCache, window: windowCache };
}
/**
* Obtiene appParser, cargándolo si es necesario
*/
export async function getAppParser() {
const { appParser } = await loadRemoteParser();
return appParser;
}
/**
* Wrapper para parseComponents usando appParser remoto
* Usa tipo 2 (Twig) explícitamente
* Firma real: parseComponents(code, prefixVar, type = 0)
*/
export async function parseComponents(html, moduleIds = [], listTables = [], prefixVar = "", skipBuilderData = false) {
const { appParser, window } = await loadRemoteParser();
// Setear las variables globales en el window real donde se cargó appParser
window.allModules = moduleIds;
window.tables = listTables;
// La firma real es: parseComponents(code, prefixVar, type = 0)
// Pasamos 2 (número) para Twig explícitamente
return appParser.parseComponents(html, prefixVar, 2);
}
/**
* Wrapper para generateBuilderVars usando appParser remoto
* Usa tipo 2 (Twig) explícitamente, igual que en Api.js
* Nota: El código remoto falla si previousSchema es null, así que pasamos {} en su lugar
*/
export async function generateBuilderVars(code, previousSchema = null) {
const appParser = await getAppParser();
// Pasar 2 para Twig (igual que en Api.js: parseInt(type) donde type="2")
// El código remoto falla si previousSchema es null, así que usamos {} en su lugar
const safePreviousSchema = previousSchema || {};
return appParser.generateBuilderVars(code, 2, safePreviousSchema);
}
/**
* Limpia la caché (útil para forzar recarga)
*/
export function clearCache() {
appParserCache = null;
scriptsCache = null;
}