+```
+El primer campo de cada tab NO lleva `mt-6` (no hay campo previo).
\ No newline at end of file
diff --git a/execute-requirements.md b/execute-requirements.md
new file mode 100644
index 0000000..4cc02a6
--- /dev/null
+++ b/execute-requirements.md
@@ -0,0 +1,265 @@
+# 🚀 AGENTIC MICROSERVICE MASTER PROMPT V3 (CLAUDE OPUS -- FULL CONTROL MODE)
+
+## SYSTEM ROLE
+
+You are a Principal / Staff Engineer operating at production level.
+
+You specialize in: - Agentic systems - Context engineering - Distributed
+backend systems - LLM orchestration - Tool-based architectures (MCP) -
+Multi-agent systems
+
+You do NOT produce demos. You build production-grade systems.
+
+------------------------------------------------------------------------
+
+## EXECUTION CONTRACT (STRICT)
+
+You MUST:
+
+- Think like a system architect AND implementer
+- Build a COMPLETE system (not partial)
+- Avoid unnecessary explanations
+- Deliver REAL, executable code
+- Avoid placeholders unless unavoidable
+- Make decisions without asking unless critical
+- Optimize for correctness, extensibility, and clarity
+
+------------------------------------------------------------------------
+
+## GLOBAL OBJECTIVE
+
+Design and IMPLEMENT a production-ready microservice that:
+
+- Manages persistent agent sessions
+- Implements advanced context management (NO chat history)
+- Connects to MCP via stdio
+- Supports multi-model providers
+- Implements subagents + orchestrator
+- Streams via SSE
+- Is dockerized and deployable
+
+------------------------------------------------------------------------
+
+# ⚠️ EXECUTION PHASES (MANDATORY)
+
+You MUST structure your response in EXACTLY these phases:
+
+------------------------------------------------------------------------
+
+## PHASE 1 --- ARCHITECTURE
+
+Define:
+
+- High-level architecture
+- Core components
+- Data flow
+- Trade-offs
+- Justify key decisions briefly
+
+DO NOT over-explain.
+
+------------------------------------------------------------------------
+
+## PHASE 2 --- CORE DATA MODELS
+
+Define strongly typed models for:
+
+- SessionState
+- TaskState
+- ArtifactSummary
+- ToolExecution
+- MemoryDocument
+- ContextPackage
+- AgentProfile
+- SubAgentDefinition
+
+Use Pydantic v2.
+
+------------------------------------------------------------------------
+
+## PHASE 3 --- CONTEXT ENGINE (CRITICAL)
+
+Implement a professional Context Manager:
+
+It MUST:
+
+- Build prompts from structured state
+- Separate:
+ - immutable_rules
+ - project_profile
+ - task_state
+ - artifact_memory
+ - working_context
+- Never include raw tool outputs
+- Perform compaction:
+ - extract facts
+ - remove redundancy
+ - maintain constraints
+- Support artifact summarization
+- Support selective rehydration
+
+This is the MOST IMPORTANT part of the system.
+
+------------------------------------------------------------------------
+
+## PHASE 4 --- MEMORY SYSTEM
+
+Implement:
+
+- Persistent memory (rules, docs)
+- Artifact memory (summaries)
+- Optional embeddings (ONLY if useful)
+
+------------------------------------------------------------------------
+
+## PHASE 5 --- MODEL ADAPTER LAYER
+
+Define interface:
+
+``` python
+class ModelAdapter:
+ async def stream(self, messages, tools, config): ...
+```
+
+Implement at least ONE real adapter (Claude or OpenAI).
+
+Design for extensibility.
+
+------------------------------------------------------------------------
+
+## PHASE 6 --- MCP CLIENT
+
+Implement stdio-based MCP client:
+
+- process lifecycle
+- request/response handling
+- timeouts
+- tool registry
+
+------------------------------------------------------------------------
+
+## PHASE 7 --- AGENT ORCHESTRATOR
+
+Implement:
+
+- planner agent
+- coder agent
+- context collector
+- reviewer
+
+Include:
+
+- routing logic
+- subagent selection
+- controlled context per agent
+- execution loop
+
+------------------------------------------------------------------------
+
+## PHASE 8 --- SSE STREAMING
+
+Implement:
+
+- SSE endpoint
+- event emitter
+- structured events:
+ - session.created
+ - execution.started
+ - agent.delta
+ - tool.started
+ - tool.completed
+ - subagent.assigned
+ - execution.completed
+
+------------------------------------------------------------------------
+
+## PHASE 9 --- API LAYER
+
+Endpoints:
+
+POST /sessions\
+POST /sessions/{id}/messages\
+GET /sessions/{id}/stream\
+GET /sessions/{id}\
+DELETE /sessions/{id}
+
+------------------------------------------------------------------------
+
+## PHASE 10 --- REDIS DESIGN
+
+Design structured storage:
+
+- session:{id}
+- session:{id}:state
+- session:{id}:artifacts
+- session:{id}:events
+
+------------------------------------------------------------------------
+
+## PHASE 11 --- FULL IMPLEMENTATION
+
+Provide:
+
+- Full working code
+- Clean architecture
+- No missing dependencies
+- Async-first design
+
+------------------------------------------------------------------------
+
+## PHASE 12 --- DOCKERIZATION
+
+Provide:
+
+- Dockerfile
+- Run instructions
+
+------------------------------------------------------------------------
+
+## PHASE 13 --- README
+
+Explain:
+
+- How to run
+- How to test
+- Example requests
+
+------------------------------------------------------------------------
+
+# ⚠️ CONTEXT RULES (STRICT)
+
+The model MUST NEVER receive:
+
+- full tool outputs
+- large logs
+- raw code dumps (unless needed for current step)
+
+The model MUST receive:
+
+- summarized state
+- minimal working context
+- rules
+
+------------------------------------------------------------------------
+
+# ⚠️ ANTI-FAILURE RULES
+
+- Do NOT stop halfway
+- Do NOT simplify critical systems
+- Do NOT omit context manager logic
+- Do NOT skip MCP or orchestrator
+- Do NOT produce pseudo-architecture without code
+
+------------------------------------------------------------------------
+
+# SUCCESS CRITERIA
+
+The system must perform:
+
+session → message → plan → tool → summarize → update → stream
+
+------------------------------------------------------------------------
+
+# BEGIN
+
+Execute ALL phases and build the full system.
diff --git a/mcp-server/.dockerignore b/mcp-server/.dockerignore
new file mode 100644
index 0000000..9098a51
--- /dev/null
+++ b/mcp-server/.dockerignore
@@ -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
diff --git a/mcp-server/.env.example b/mcp-server/.env.example
new file mode 100644
index 0000000..df766d7
--- /dev/null
+++ b/mcp-server/.env.example
@@ -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
diff --git a/mcp-server/Dockerfile b/mcp-server/Dockerfile
new file mode 100644
index 0000000..c4a7f45
--- /dev/null
+++ b/mcp-server/Dockerfile
@@ -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"]
diff --git a/mcp-server/README.md b/mcp-server/README.md
new file mode 100644
index 0000000..d0b0e14
--- /dev/null
+++ b/mcp-server/README.md
@@ -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
+
+# 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
diff --git a/mcp-server/acai-file-tools.json b/mcp-server/acai-file-tools.json
new file mode 100644
index 0000000..19e200c
--- /dev/null
+++ b/mcp-server/acai-file-tools.json
@@ -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": "\n
{{getVar('titulo')}} \n",
+ "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": "\n
{{getVar('titulo')}} \n...\n",
+ "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": "\n {{getVar('titulo')}} \n {{getVar('subtitulo')}}
\n ",
+ "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"
+ }
+ }
+}
diff --git a/mcp-server/auth/apiClient.js b/mcp-server/auth/apiClient.js
new file mode 100644
index 0000000..fc426a6
--- /dev/null
+++ b/mcp-server/auth/apiClient.js
@@ -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 });
+ };
+};
diff --git a/mcp-server/auth/credentials.js b/mcp-server/auth/credentials.js
new file mode 100644
index 0000000..c72b5e7
--- /dev/null
+++ b/mcp-server/auth/credentials.js
@@ -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;
+};
diff --git a/mcp-server/auth/index.js b/mcp-server/auth/index.js
new file mode 100644
index 0000000..00d4d02
--- /dev/null
+++ b/mcp-server/auth/index.js
@@ -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';
+
diff --git a/mcp-server/auth/localClient.js b/mcp-server/auth/localClient.js
new file mode 100644
index 0000000..7d41102
--- /dev/null
+++ b/mcp-server/auth/localClient.js
@@ -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: [...] }
+}
diff --git a/mcp-server/auth/redisClient.js b/mcp-server/auth/redisClient.js
new file mode 100644
index 0000000..692e685
--- /dev/null
+++ b/mcp-server/auth/redisClient.js
@@ -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);
+ }
+ }
+}
diff --git a/mcp-server/cluster.js b/mcp-server/cluster.js
new file mode 100644
index 0000000..596f955
--- /dev/null
+++ b/mcp-server/cluster.js
@@ -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);
+ });
+}
diff --git a/mcp-server/config/index.js b/mcp-server/config/index.js
new file mode 100644
index 0000000..6a7c7a0
--- /dev/null
+++ b/mcp-server/config/index.js
@@ -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}`);
+};
+
diff --git a/mcp-server/debug_client.py b/mcp-server/debug_client.py
new file mode 100644
index 0000000..4a85139
--- /dev/null
+++ b/mcp-server/debug_client.py
@@ -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()
diff --git a/mcp-server/docker-compose.prod.yml b/mcp-server/docker-compose.prod.yml
new file mode 100644
index 0000000..321ed34
--- /dev/null
+++ b/mcp-server/docker-compose.prod.yml
@@ -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
diff --git a/mcp-server/docker-compose.yml b/mcp-server/docker-compose.yml
new file mode 100644
index 0000000..9e111a0
--- /dev/null
+++ b/mcp-server/docker-compose.yml
@@ -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:
diff --git a/mcp-server/fieldData.json b/mcp-server/fieldData.json
new file mode 100644
index 0000000..0ac78c5
--- /dev/null
+++ b/mcp-server/fieldData.json
@@ -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": " ",
+ "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
+}
\ No newline at end of file
diff --git a/mcp-server/http-mcp-client.js b/mcp-server/http-mcp-client.js
new file mode 100644
index 0000000..da67127
--- /dev/null
+++ b/mcp-server/http-mcp-client.js
@@ -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 [--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 [--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();
diff --git a/mcp-server/httpServer.js b/mcp-server/httpServer.js
new file mode 100644
index 0000000..fb51a30
--- /dev/null
+++ b/mcp-server/httpServer.js
@@ -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 "";
+ 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 };
diff --git a/mcp-server/httpServer.sse.backup b/mcp-server/httpServer.sse.backup
new file mode 100644
index 0000000..1e85208
--- /dev/null
+++ b/mcp-server/httpServer.sse.backup
@@ -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 };
+
+
diff --git a/mcp-server/index.js b/mcp-server/index.js
new file mode 100644
index 0000000..07e54dc
--- /dev/null
+++ b/mcp-server/index.js
@@ -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);
+
diff --git a/mcp-server/index.new.js b/mcp-server/index.new.js
new file mode 100644
index 0000000..bb75765
--- /dev/null
+++ b/mcp-server/index.new.js
@@ -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);
+
+
diff --git a/mcp-server/monitor.html b/mcp-server/monitor.html
new file mode 100644
index 0000000..1ee560a
--- /dev/null
+++ b/mcp-server/monitor.html
@@ -0,0 +1,917 @@
+
+
+
+
+
+ Monitor MCP
+
+
+
+
+
+
+
+
+
+
+
+
Elige una petición para ver el payload y la respuesta.
+
+
+
+
+
+
+
+
+ Cargando sesiones...
+
+
+
+
+
+
+
+
+
+ Cargando estadisticas...
+
+
+
+
+
+
+
+
+
diff --git a/mcp-server/monitor.js b/mcp-server/monitor.js
new file mode 100644
index 0000000..c20c8d8
--- /dev/null
+++ b/mcp-server/monitor.js
@@ -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 `
+
+
+
+ MCP Monitor
+
+
+
+ MCP Monitor
+ No se encontró el archivo de interfaz en ${monitorHtmlPath}.
+
+`;
+ }
+}
+
+/**
+ * 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;
+}
+
+
diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json
new file mode 100644
index 0000000..8157a55
--- /dev/null
+++ b/mcp-server/package-lock.json
@@ -0,0 +1,3124 @@
+{
+ "name": "acai-code-mcp-server",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "acai-code-mcp-server",
+ "version": "1.0.0",
+ "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"
+ }
+ },
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.31",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
+ "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
+ "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==",
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.0.0",
+ "@csstools/css-color-parser": "^4.0.1",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "lru-cache": "^11.2.5"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "6.8.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
+ "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.1.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.2.6"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "license": "MIT"
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
+ "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
+ "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
+ "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.0.2",
+ "@csstools/css-calc": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz",
+ "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
+ "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
+ "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@hono/node-server": {
+ "version": "1.19.11",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
+ "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.14.1"
+ },
+ "peerDependencies": {
+ "hono": "^4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
+ "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
+ "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
+ "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
+ "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
+ "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
+ "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
+ "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
+ "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
+ "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
+ "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
+ "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.0.5"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
+ "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
+ "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
+ "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
+ "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
+ "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
+ "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.2.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
+ "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
+ "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk": {
+ "version": "1.27.1",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
+ "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
+ "license": "MIT",
+ "dependencies": {
+ "@hono/node-server": "^1.19.9",
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1",
+ "content-type": "^1.0.5",
+ "cors": "^2.8.5",
+ "cross-spawn": "^7.0.5",
+ "eventsource": "^3.0.2",
+ "eventsource-parser": "^3.0.0",
+ "express": "^5.2.1",
+ "express-rate-limit": "^8.2.1",
+ "hono": "^4.11.4",
+ "jose": "^6.1.3",
+ "json-schema-typed": "^8.0.2",
+ "pkce-challenge": "^5.0.0",
+ "raw-body": "^3.0.0",
+ "zod": "^3.25 || ^4.0",
+ "zod-to-json-schema": "^3.25.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@cfworker/json-schema": "^4.1.1",
+ "zod": "^3.25 || ^4.0"
+ },
+ "peerDependenciesMeta": {
+ "@cfworker/json-schema": {
+ "optional": true
+ },
+ "zod": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/@playwright/mcp": {
+ "version": "0.0.68",
+ "resolved": "https://registry.npmjs.org/@playwright/mcp/-/mcp-0.0.68.tgz",
+ "integrity": "sha512-oP9I9ghXKuQEBo4xaC7HgsS2gRTxyMzlBm3UEhYj4VqqrqbPQUX2shATPaNA/am9joBzq9v0OXISzeIgP+zmHA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.59.0-alpha-1771104257000",
+ "playwright-core": "1.59.0-alpha-1771104257000"
+ },
+ "bin": {
+ "playwright-mcp": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@redis/bloom": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
+ "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/client": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
+ "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
+ "license": "MIT",
+ "dependencies": {
+ "cluster-key-slot": "1.1.2",
+ "generic-pool": "3.9.0",
+ "yallist": "4.0.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@redis/graph": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
+ "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/json": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
+ "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/search": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
+ "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/time-series": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
+ "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.13.6",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
+ "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.11",
+ "form-data": "^4.0.5",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+ "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.7.0",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.1",
+ "raw-body": "^3.0.1",
+ "type-is": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/body-parser/node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+ "license": "ISC"
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
+ "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/cheerio": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
+ "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
+ "license": "MIT",
+ "dependencies": {
+ "cheerio-select": "^2.1.0",
+ "dom-serializer": "^2.0.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "encoding-sniffer": "^0.2.1",
+ "htmlparser2": "^10.1.0",
+ "parse5": "^7.3.0",
+ "parse5-htmlparser2-tree-adapter": "^7.1.0",
+ "parse5-parser-stream": "^7.1.2",
+ "undici": "^7.19.0",
+ "whatwg-mimetype": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=20.18.1"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/cheerio?sponsor=1"
+ }
+ },
+ "node_modules/cheerio-select": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
+ "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-select": "^5.1.0",
+ "css-what": "^6.1.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/color": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
+ "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1",
+ "color-string": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=12.5.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/color-string": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "^1.0.0",
+ "simple-swizzle": "^0.2.2"
+ }
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
+ "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cors": {
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/css-select": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
+ "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.1.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "nth-check": "^2.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
+ "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">= 6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/cssstyle": {
+ "version": "5.3.7",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz",
+ "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^4.1.1",
+ "@csstools/css-syntax-patches-for-csstree": "^1.0.21",
+ "css-tree": "^3.1.0",
+ "lru-cache": "^11.2.4"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/data-urls": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz",
+ "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^15.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/data-urls/node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "license": "MIT"
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/encoding-sniffer": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
+ "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "^0.6.3",
+ "whatwg-encoding": "^3.1.1"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
+ }
+ },
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/eventsource": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+ "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+ "license": "MIT",
+ "dependencies": {
+ "eventsource-parser": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/eventsource-parser": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
+ "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "8.3.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
+ "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "10.1.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
+ "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/form-data/node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/form-data/node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/generic-pool": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
+ "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hono": {
+ "version": "4.12.9",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
+ "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ignore-by-default": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
+ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ip-address": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
+ "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
+ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
+ "license": "MIT"
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "license": "MIT"
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/jose": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
+ "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/jsdom": {
+ "version": "27.4.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz",
+ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@acemir/cssom": "^0.9.28",
+ "@asamuzakjp/dom-selector": "^6.7.6",
+ "@exodus/bytes": "^1.6.0",
+ "cssstyle": "^5.3.4",
+ "data-urls": "^6.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "parse5": "^8.0.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.0",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^15.1.0",
+ "ws": "^8.18.3",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/jsdom/node_modules/parse5": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
+ "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/json-schema-typed": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/lru-cache": {
+ "version": "11.2.7",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
+ "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "license": "CC0-1.0"
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
+ "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/nodemon": {
+ "version": "3.1.14",
+ "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
+ "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^3.5.2",
+ "debug": "^4",
+ "ignore-by-default": "^1.0.1",
+ "minimatch": "^10.2.1",
+ "pstree.remy": "^1.1.8",
+ "semver": "^7.5.3",
+ "simple-update-notifier": "^2.0.0",
+ "supports-color": "^5.5.0",
+ "touch": "^3.1.0",
+ "undefsafe": "^2.0.5"
+ },
+ "bin": {
+ "nodemon": "bin/nodemon.js"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/nodemon"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5-htmlparser2-tree-adapter": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
+ "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
+ "license": "MIT",
+ "dependencies": {
+ "domhandler": "^5.0.3",
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5-parser-stream": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
+ "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
+ "license": "MIT",
+ "dependencies": {
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5/node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
+ "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pkce-challenge": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
+ "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.59.0-alpha-1771104257000",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.0-alpha-1771104257000.tgz",
+ "integrity": "sha512-6SCMMMJaDRsSqiKVLmb2nhtLES7iTYawTWWrQK6UdIGNzXi8lka4sLKRec3L4DnTWwddAvCuRn8035dhNiHzbg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.59.0-alpha-1771104257000"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.59.0-alpha-1771104257000",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0-alpha-1771104257000.tgz",
+ "integrity": "sha512-YiXup3pnpQUCBMSIW5zx8CErwRx4K6O5Kojkw2BzJui8MazoMUDU6E3xGsb1kzFviEAE09LFQ+y1a0RhIJQ5SA==",
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
+ "node_modules/pstree.remy": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
+ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
+ "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/raw-body/node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/redis": {
+ "version": "4.7.1",
+ "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
+ "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
+ "license": "MIT",
+ "workspaces": [
+ "./packages/*"
+ ],
+ "dependencies": {
+ "@redis/bloom": "1.2.0",
+ "@redis/client": "1.6.1",
+ "@redis/graph": "1.1.1",
+ "@redis/json": "1.0.7",
+ "@redis/search": "1.2.0",
+ "@redis/time-series": "1.1.0"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/sharp": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
+ "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "color": "^4.2.3",
+ "detect-libc": "^2.0.3",
+ "semver": "^7.6.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.33.5",
+ "@img/sharp-darwin-x64": "0.33.5",
+ "@img/sharp-libvips-darwin-arm64": "1.0.4",
+ "@img/sharp-libvips-darwin-x64": "1.0.4",
+ "@img/sharp-libvips-linux-arm": "1.0.5",
+ "@img/sharp-libvips-linux-arm64": "1.0.4",
+ "@img/sharp-libvips-linux-s390x": "1.0.4",
+ "@img/sharp-libvips-linux-x64": "1.0.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.0.4",
+ "@img/sharp-linux-arm": "0.33.5",
+ "@img/sharp-linux-arm64": "0.33.5",
+ "@img/sharp-linux-s390x": "0.33.5",
+ "@img/sharp-linux-x64": "0.33.5",
+ "@img/sharp-linuxmusl-arm64": "0.33.5",
+ "@img/sharp-linuxmusl-x64": "0.33.5",
+ "@img/sharp-wasm32": "0.33.5",
+ "@img/sharp-win32-ia32": "0.33.5",
+ "@img/sharp-win32-x64": "0.33.5"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/simple-swizzle": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
+ "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.3.1"
+ }
+ },
+ "node_modules/simple-update-notifier": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
+ "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "license": "MIT"
+ },
+ "node_modules/tldts": {
+ "version": "7.0.27",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz",
+ "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==",
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.0.27"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.0.27",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz",
+ "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==",
+ "license": "MIT"
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/touch": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
+ "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "nodetouch": "bin/nodetouch.js"
+ }
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
+ "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/type-is": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/undefsafe": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
+ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/undici": {
+ "version": "7.24.5",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz",
+ "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz",
+ "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/ws": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "license": "MIT"
+ },
+ "node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "license": "ISC"
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-to-json-schema": {
+ "version": "3.25.1",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
+ "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "zod": "^3.25 || ^4"
+ }
+ }
+ }
+}
diff --git a/mcp-server/package.json b/mcp-server/package.json
new file mode 100644
index 0000000..0b900f2
--- /dev/null
+++ b/mcp-server/package.json
@@ -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"
+ }
+}
diff --git a/mcp-server/prompts/guiaCamposTablas.js b/mcp-server/prompts/guiaCamposTablas.js
new file mode 100644
index 0000000..66955b8
--- /dev/null
+++ b/mcp-server/prompts/guiaCamposTablas.js
@@ -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 \`\` para el prefijo.
+ - Usa \`\` 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
+ );
+}
+
+
diff --git a/mcp-server/prompts/index.js b/mcp-server/prompts/index.js
new file mode 100644
index 0000000..e943735
--- /dev/null
+++ b/mcp-server/prompts/index.js
@@ -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);
+}
diff --git a/mcp-server/requestMonitor.js b/mcp-server/requestMonitor.js
new file mode 100644
index 0000000..3692057
--- /dev/null
+++ b/mcp-server/requestMonitor.js
@@ -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);
+ }
+}
diff --git a/mcp-server/resources/index.js b/mcp-server/resources/index.js
new file mode 100644
index 0000000..ae7ea47
--- /dev/null
+++ b/mcp-server/resources/index.js
@@ -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
+}
diff --git a/mcp-server/server.js b/mcp-server/server.js
new file mode 100644
index 0000000..ecbca68
--- /dev/null
+++ b/mcp-server/server.js
@@ -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;
+}
diff --git a/mcp-server/stdio.js b/mcp-server/stdio.js
new file mode 100644
index 0000000..5381a97
--- /dev/null
+++ b/mcp-server/stdio.js
@@ -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})`);
diff --git a/mcp-server/tools/auth/index.js b/mcp-server/tools/auth/index.js
new file mode 100644
index 0000000..bbd1c86
--- /dev/null
+++ b/mcp-server/tools/auth/index.js
@@ -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,
+ };
+ }
+ }
+ );
+}
diff --git a/mcp-server/tools/files/delete.js b/mcp-server/tools/files/delete.js
new file mode 100644
index 0000000..a732e59
--- /dev/null
+++ b/mcp-server/tools/files/delete.js
@@ -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 });
+ }
+ }
+ );
+}
diff --git a/mcp-server/tools/files/glob.js b/mcp-server/tools/files/glob.js
new file mode 100644
index 0000000..fb72293
--- /dev/null
+++ b/mcp-server/tools/files/glob.js
@@ -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 });
+ }
+ }
+ );
+}
diff --git a/mcp-server/tools/files/grep.js b/mcp-server/tools/files/grep.js
new file mode 100644
index 0000000..97c9a5e
--- /dev/null
+++ b/mcp-server/tools/files/grep.js
@@ -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 });
+ }
+ }
+ );
+}
diff --git a/mcp-server/tools/files/helpers.js b/mcp-server/tools/files/helpers.js
new file mode 100644
index 0000000..6e70115
--- /dev/null
+++ b/mcp-server/tools/files/helpers.js
@@ -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,
+ };
+}
diff --git a/mcp-server/tools/files/index.js b/mcp-server/tools/files/index.js
new file mode 100644
index 0000000..143a1f2
--- /dev/null
+++ b/mcp-server/tools/files/index.js
@@ -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);
+}
diff --git a/mcp-server/tools/files/lineReplace.js b/mcp-server/tools/files/lineReplace.js
new file mode 100644
index 0000000..0340880
--- /dev/null
+++ b/mcp-server/tools/files/lineReplace.js
@@ -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 });
+ }
+ }
+ );
+}
diff --git a/mcp-server/tools/files/view.js b/mcp-server/tools/files/view.js
new file mode 100644
index 0000000..55a861b
--- /dev/null
+++ b/mcp-server/tools/files/view.js
@@ -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 });
+ }
+ }
+ );
+}
diff --git a/mcp-server/tools/files/write.js b/mcp-server/tools/files/write.js
new file mode 100644
index 0000000..9cbd3e3
--- /dev/null
+++ b/mcp-server/tools/files/write.js
@@ -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 });
+ }
+ }
+ );
+}
diff --git a/mcp-server/tools/helpers/ACAI_ENDPOINTS.md b/mcp-server/tools/helpers/ACAI_ENDPOINTS.md
new file mode 100644
index 0000000..986e9a0
--- /dev/null
+++ b/mcp-server/tools/helpers/ACAI_ENDPOINTS.md
@@ -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.
diff --git a/mcp-server/tools/helpers/ERROR_HANDLING.md b/mcp-server/tools/helpers/ERROR_HANDLING.md
new file mode 100644
index 0000000..0fe2483
--- /dev/null
+++ b/mcp-server/tools/helpers/ERROR_HANDLING.md
@@ -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.
diff --git a/mcp-server/tools/helpers/acaiHttpClient.js b/mcp-server/tools/helpers/acaiHttpClient.js
new file mode 100644
index 0000000..540209f
--- /dev/null
+++ b/mcp-server/tools/helpers/acaiHttpClient.js
@@ -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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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;
diff --git a/mcp-server/tools/helpers/authSchema.js b/mcp-server/tools/helpers/authSchema.js
new file mode 100644
index 0000000..d16600e
--- /dev/null
+++ b/mcp-server/tools/helpers/authSchema.js
@@ -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;
diff --git a/mcp-server/tools/helpers/errorHandler.js b/mcp-server/tools/helpers/errorHandler.js
new file mode 100644
index 0000000..4fd78ea
--- /dev/null
+++ b/mcp-server/tools/helpers/errorHandler.js
@@ -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);
+ }
+ };
+}
diff --git a/mcp-server/tools/helpers/fileBuilder.js b/mcp-server/tools/helpers/fileBuilder.js
new file mode 100644
index 0000000..f559e87
--- /dev/null
+++ b/mcp-server/tools/helpers/fileBuilder.js
@@ -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} 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} 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;
+}
diff --git a/mcp-server/tools/index.js b/mcp-server/tools/index.js
new file mode 100644
index 0000000..a17fb12
--- /dev/null
+++ b/mcp-server/tools/index.js
@@ -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);
+}
diff --git a/mcp-server/tools/media/generateImage.js b/mcp-server/tools/media/generateImage.js
new file mode 100644
index 0000000..c469a8f
--- /dev/null
+++ b/mcp-server/tools/media/generateImage.js
@@ -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 });
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/media/index.js b/mcp-server/tools/media/index.js
new file mode 100644
index 0000000..536989d
--- /dev/null
+++ b/mcp-server/tools/media/index.js
@@ -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);
+}
diff --git a/mcp-server/tools/media/upload.js b/mcp-server/tools/media/upload.js
new file mode 100644
index 0000000..f7b86bd
--- /dev/null
+++ b/mcp-server/tools/media/upload.js
@@ -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 });
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/media/uploadImageToAssets.js b/mcp-server/tools/media/uploadImageToAssets.js
new file mode 100644
index 0000000..a0326d4
--- /dev/null
+++ b/mcp-server/tools/media/uploadImageToAssets.js
@@ -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 });
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/modules/check.js b/mcp-server/tools/modules/check.js
new file mode 100644
index 0000000..1f8f235
--- /dev/null
+++ b/mcp-server/tools/modules/check.js
@@ -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 });
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/modules/checkUsage.js b/mcp-server/tools/modules/checkUsage.js
new file mode 100644
index 0000000..06c96c4
--- /dev/null
+++ b/mcp-server/tools/modules/checkUsage.js
@@ -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 });
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/modules/compile.js b/mcp-server/tools/modules/compile.js
new file mode 100644
index 0000000..9f6182b
--- /dev/null
+++ b/mcp-server/tools/modules/compile.js
@@ -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 });
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/modules/create.js b/mcp-server/tools/modules/create.js
new file mode 100644
index 0000000..3e3bd17
--- /dev/null
+++ b/mcp-server/tools/modules/create.js
@@ -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 });
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/modules/index.js b/mcp-server/tools/modules/index.js
new file mode 100644
index 0000000..2ab7036
--- /dev/null
+++ b/mcp-server/tools/modules/index.js
@@ -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);
+}
diff --git a/mcp-server/tools/modules/setExampleData.js b/mcp-server/tools/modules/setExampleData.js
new file mode 100644
index 0000000..c9beb9e
--- /dev/null
+++ b/mcp-server/tools/modules/setExampleData.js
@@ -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 });
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/navigation/index.js b/mcp-server/tools/navigation/index.js
new file mode 100644
index 0000000..6682e32
--- /dev/null
+++ b/mcp-server/tools/navigation/index.js
@@ -0,0 +1,5 @@
+import { registerNavigateBrowserTool } from './navigate.js';
+
+export function registerNavigationTools(server) {
+ registerNavigateBrowserTool(server);
+}
diff --git a/mcp-server/tools/navigation/navigate.js b/mcp-server/tools/navigation/navigate.js
new file mode 100644
index 0000000..2a029da
--- /dev/null
+++ b/mcp-server/tools/navigation/navigate.js
@@ -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 });
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/orchestrator/detector.js b/mcp-server/tools/orchestrator/detector.js
new file mode 100644
index 0000000..6fe1745
--- /dev/null
+++ b/mcp-server/tools/orchestrator/detector.js
@@ -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 };
diff --git a/mcp-server/tools/orchestrator/index.js b/mcp-server/tools/orchestrator/index.js
new file mode 100644
index 0000000..19ec1fd
--- /dev/null
+++ b/mcp-server/tools/orchestrator/index.js
@@ -0,0 +1,5 @@
+import { registerOrchestrateTool } from "./orchestrate.js";
+
+export function registerOrchestratorTools(server) {
+ registerOrchestrateTool(server);
+}
diff --git a/mcp-server/tools/orchestrator/orchestrate.js b/mcp-server/tools/orchestrator/orchestrate.js
new file mode 100644
index 0000000..d8d0e16
--- /dev/null
+++ b/mcp-server/tools/orchestrator/orchestrate.js
@@ -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
+ };
+ }
+ }
+ );
+}
diff --git a/mcp-server/tools/orchestrator/workflows/createModule.js b/mcp-server/tools/orchestrator/workflows/createModule.js
new file mode 100644
index 0000000..7bc48a4
--- /dev/null
+++ b/mcp-server/tools/orchestrator/workflows/createModule.js
@@ -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"
+ ]
+};
diff --git a/mcp-server/tools/orchestrator/workflows/createSection.js b/mcp-server/tools/orchestrator/workflows/createSection.js
new file mode 100644
index 0000000..f656631
--- /dev/null
+++ b/mcp-server/tools/orchestrator/workflows/createSection.js
@@ -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"
+ ]
+};
diff --git a/mcp-server/tools/orchestrator/workflows/editModule.js b/mcp-server/tools/orchestrator/workflows/editModule.js
new file mode 100644
index 0000000..ffeaff0
--- /dev/null
+++ b/mcp-server/tools/orchestrator/workflows/editModule.js
@@ -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"
+ ]
+};
diff --git a/mcp-server/tools/orchestrator/workflows/exploreSite.js b/mcp-server/tools/orchestrator/workflows/exploreSite.js
new file mode 100644
index 0000000..ea4a4d8
--- /dev/null
+++ b/mcp-server/tools/orchestrator/workflows/exploreSite.js
@@ -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: []
+};
diff --git a/mcp-server/tools/orchestrator/workflows/index.js b/mcp-server/tools/orchestrator/workflows/index.js
new file mode 100644
index 0000000..5010774
--- /dev/null
+++ b/mcp-server/tools/orchestrator/workflows/index.js
@@ -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,
+ }));
+}
diff --git a/mcp-server/tools/orchestrator/workflows/manageMedia.js b/mcp-server/tools/orchestrator/workflows/manageMedia.js
new file mode 100644
index 0000000..7dacbba
--- /dev/null
+++ b/mcp-server/tools/orchestrator/workflows/manageMedia.js
@@ -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: []
+};
diff --git a/mcp-server/tools/orchestrator/workflows/manageRecords.js b/mcp-server/tools/orchestrator/workflows/manageRecords.js
new file mode 100644
index 0000000..7de08f7
--- /dev/null
+++ b/mcp-server/tools/orchestrator/workflows/manageRecords.js
@@ -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"
+ ]
+};
diff --git a/mcp-server/tools/orchestrator/workflows/populateContent.js b/mcp-server/tools/orchestrator/workflows/populateContent.js
new file mode 100644
index 0000000..49d5a5b
--- /dev/null
+++ b/mcp-server/tools/orchestrator/workflows/populateContent.js
@@ -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"
+ ]
+};
diff --git a/mcp-server/tools/orchestrator/workflows/seoSetup.js b/mcp-server/tools/orchestrator/workflows/seoSetup.js
new file mode 100644
index 0000000..d45119d
--- /dev/null
+++ b/mcp-server/tools/orchestrator/workflows/seoSetup.js
@@ -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 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: []
+};
diff --git a/mcp-server/tools/project/index.js b/mcp-server/tools/project/index.js
new file mode 100644
index 0000000..438ebf7
--- /dev/null
+++ b/mcp-server/tools/project/index.js
@@ -0,0 +1,5 @@
+import { registerSaveProjectStylesTool } from "./saveStyles.js";
+
+export function registerProjectTools(server) {
+ registerSaveProjectStylesTool(server);
+}
diff --git a/mcp-server/tools/project/saveStyles.js b/mcp-server/tools/project/saveStyles.js
new file mode 100644
index 0000000..935c9fd
--- /dev/null
+++ b/mcp-server/tools/project/saveStyles.js
@@ -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", {});
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/records/addModuleToRecord.js b/mcp-server/tools/records/addModuleToRecord.js
new file mode 100644
index 0000000..0aacd0e
--- /dev/null
+++ b/mcp-server/tools/records/addModuleToRecord.js
@@ -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 });
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/records/createUpdate.js b/mcp-server/tools/records/createUpdate.js
new file mode 100644
index 0000000..2d522c0
--- /dev/null
+++ b/mcp-server/tools/records/createUpdate.js
@@ -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) });
+ }
+ })
+ );
+}
+
diff --git a/mcp-server/tools/records/delete.js b/mcp-server/tools/records/delete.js
new file mode 100644
index 0000000..a9daad3
--- /dev/null
+++ b/mcp-server/tools/records/delete.js
@@ -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 });
+ }
+ })
+ );
+}
+
diff --git a/mcp-server/tools/records/getModuleConfigVars.js b/mcp-server/tools/records/getModuleConfigVars.js
new file mode 100644
index 0000000..ed36e8e
--- /dev/null
+++ b/mcp-server/tools/records/getModuleConfigVars.js
@@ -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 });
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/records/getRecord.js b/mcp-server/tools/records/getRecord.js
new file mode 100644
index 0000000..dc3a345
--- /dev/null
+++ b/mcp-server/tools/records/getRecord.js
@@ -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 });
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/records/index.js b/mcp-server/tools/records/index.js
new file mode 100644
index 0000000..f31176b
--- /dev/null
+++ b/mcp-server/tools/records/index.js
@@ -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);
+}
+
diff --git a/mcp-server/tools/records/list.js b/mcp-server/tools/records/list.js
new file mode 100644
index 0000000..2e0eb92
--- /dev/null
+++ b/mcp-server/tools/records/list.js
@@ -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 });
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/records/listPageModules.js b/mcp-server/tools/records/listPageModules.js
new file mode 100644
index 0000000..f7b8083
--- /dev/null
+++ b/mcp-server/tools/records/listPageModules.js
@@ -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 });
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/records/removeModuleFromRecord.js b/mcp-server/tools/records/removeModuleFromRecord.js
new file mode 100644
index 0000000..e4cd4d7
--- /dev/null
+++ b/mcp-server/tools/records/removeModuleFromRecord.js
@@ -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 });
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/records/reorderModule.js b/mcp-server/tools/records/reorderModule.js
new file mode 100644
index 0000000..11c10ea
--- /dev/null
+++ b/mcp-server/tools/records/reorderModule.js
@@ -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 });
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/records/setModuleConfigVars.js b/mcp-server/tools/records/setModuleConfigVars.js
new file mode 100644
index 0000000..b931bf4
--- /dev/null
+++ b/mcp-server/tools/records/setModuleConfigVars.js
@@ -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 });
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/records/toggleModuleVisibility.js b/mcp-server/tools/records/toggleModuleVisibility.js
new file mode 100644
index 0000000..b9e6ef0
--- /dev/null
+++ b/mcp-server/tools/records/toggleModuleVisibility.js
@@ -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 });
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/remote_git/index.js b/mcp-server/tools/remote_git/index.js
new file mode 100644
index 0000000..d7aed26
--- /dev/null
+++ b/mcp-server/tools/remote_git/index.js
@@ -0,0 +1,5 @@
+import { registerRecoverGitTools } from './rollback.js';
+
+export function registerRemoteGitTools(server) {
+ registerRecoverGitTools(server);
+}
diff --git a/mcp-server/tools/remote_git/rollback.js b/mcp-server/tools/remote_git/rollback.js
new file mode 100644
index 0000000..63f7219
--- /dev/null
+++ b/mcp-server/tools/remote_git/rollback.js
@@ -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');
+ }
+ })
+ );
+}
diff --git a/mcp-server/tools/tables/create.js b/mcp-server/tools/tables/create.js
new file mode 100644
index 0000000..35237ec
--- /dev/null
+++ b/mcp-server/tools/tables/create.js
@@ -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 });
+ }
+ })
+ );
+}
+
+
diff --git a/mcp-server/tools/tables/delete.js b/mcp-server/tools/tables/delete.js
new file mode 100644
index 0000000..1b04b48
--- /dev/null
+++ b/mcp-server/tools/tables/delete.js
@@ -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 });
+ }
+ })
+ );
+}
+
+
diff --git a/mcp-server/tools/tables/fields.js b/mcp-server/tools/tables/fields.js
new file mode 100644
index 0000000..0e6ee16
--- /dev/null
+++ b/mcp-server/tools/tables/fields.js
@@ -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 });
+ }
+ })
+ );
+}
+
+
diff --git a/mcp-server/tools/tables/index.js b/mcp-server/tools/tables/index.js
new file mode 100644
index 0000000..5aebdc6
--- /dev/null
+++ b/mcp-server/tools/tables/index.js
@@ -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);
+}
+
+
diff --git a/mcp-server/tools/tables/list.js b/mcp-server/tools/tables/list.js
new file mode 100644
index 0000000..0ac5215
--- /dev/null
+++ b/mcp-server/tools/tables/list.js
@@ -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');
+ }
+ })
+ );
+}
+
+
diff --git a/mcp-server/tools/tables/schema.js b/mcp-server/tools/tables/schema.js
new file mode 100644
index 0000000..0159f4c
--- /dev/null
+++ b/mcp-server/tools/tables/schema.js
@@ -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 });
+ }
+ })
+ );
+}
diff --git a/mcp-server/utils/cmsTargetSafety.js b/mcp-server/utils/cmsTargetSafety.js
new file mode 100644
index 0000000..e65cdb1
--- /dev/null
+++ b/mcp-server/utils/cmsTargetSafety.js
@@ -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 || ""}`);
+ }
+}
+
+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);
+}
+
diff --git a/mcp-server/utils/fieldHelpers.js b/mcp-server/utils/fieldHelpers.js
new file mode 100644
index 0000000..b913947
--- /dev/null
+++ b/mcp-server/utils/fieldHelpers.js
@@ -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;
+};
+
+
diff --git a/mcp-server/utils/helpers.js b/mcp-server/utils/helpers.js
new file mode 100644
index 0000000..1c4dbd6
--- /dev/null
+++ b/mcp-server/utils/helpers.js
@@ -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}`;
+
+
diff --git a/mcp-server/utils/index.js b/mcp-server/utils/index.js
new file mode 100644
index 0000000..4b64dfd
--- /dev/null
+++ b/mcp-server/utils/index.js
@@ -0,0 +1,4 @@
+export * from './helpers.js';
+export * from './fieldHelpers.js';
+
+
diff --git a/mcp-server/utils/mixins/builderdata.js b/mcp-server/utils/mixins/builderdata.js
new file mode 100644
index 0000000..ae02d23
--- /dev/null
+++ b/mcp-server/utils/mixins/builderdata.js
@@ -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
+ Nota : Los componentes de estos ejemplos pueden variar dependiendo del analizador léxico utilizado.`,
+ example : `Elemento editable
+
+Elemento editable
+
+Elemento editable
+
+Elemento editable
+
+Elemento editable
+
+
+
+Elemento editable
+
+
+
+ {{record.name}}
+
+
+
+
+Si se desea que sea multi añadir el atributo data-list-multi
+
+
+
+
+
+
+
+
+ NOTA : El Multi debe tener un nodo padre ( sin hermanos ).
+
+ Elemento editable
+ Elemento editable
+
+
+
+
+
+
+`,
+ shortcode : ``,
+ directLinks : {
+ TextField : `Elemento editable
`,
+ HeadField : `Elemento editable
`,
+ 'List (Options)' : `
`,
+ 'List (Table)' : `
+ {{record.name}}
+
`,
+ Link : `Elemento editable `,
+ TextBox : `Elemento editable
`,
+ Wysiwyg : `Elemento editable
`,
+ Upload : ``,
+ UploadBackground : `Elemento editable
`,
+ UploadMulti : `
+
+
`,
+ Multi : `
+
+ Elemento editable
+ Elemento editable
+
+
+ `,
+ },
+ shortcuts: {
+ 'C-Form' : `
+ `,
+ Hook: ` `,
+ Dump: `{{dump(thisrecord)}} `,
+ 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 ? `${appParser.parseComponents(el.innerHTML,prefixVar)} ` : `${php2} `;
+ }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 = ` `;
+
+ }
+ 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 = `${phph2} ${phph1} ${phph3} `;
+
+ 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 ? `${appParser.parseComponents(el.innerHTML,prefixVar)} ` : `${php2} `;
+ }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 = ` `;
+ }
+ 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;
+
+ }
+ }
+};
\ No newline at end of file
diff --git a/mcp-server/utils/moduleParser.js b/mcp-server/utils/moduleParser.js
new file mode 100644
index 0000000..576bf8f
--- /dev/null
+++ b/mcp-server/utils/moduleParser.js
@@ -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} - 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);
+ }
+}
diff --git a/mcp-server/utils/remoteParser.js b/mcp-server/utils/remoteParser.js
new file mode 100644
index 0000000..5e3a949
--- /dev/null
+++ b/mcp-server/utils/remoteParser.js
@@ -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('', {
+ 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;
+}
+
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..4e5ecd3
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,10 @@
+fastapi>=0.115.0,<1.0.0
+uvicorn[standard]>=0.34.0,<1.0.0
+pydantic>=2.10.0,<3.0.0
+pydantic-settings>=2.7.0,<3.0.0
+redis[hiredis]>=5.2.0,<6.0.0
+anthropic>=0.42.0,<1.0.0
+openai>=1.60.0,<2.0.0
+httpx>=0.28.0,<1.0.0
+sse-starlette>=2.2.0,<3.0.0
+tiktoken>=0.7.0,<1.0.0
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/__pycache__/__init__.cpython-312.pyc b/src/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..4b00cdb48a2e9e233e9b1f2ef3dcdaf007354c5f
GIT binary patch
literal 167
zcmX@j%ge<81omxbGePuY5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!a?=kjPAw|d&&n@K
zNz6;hOsvv($xklLP0cGQ)-Nc^PcKT$O-#D`Ev2%Lv59AjU^#Mn=XWW*`dy@W?9v
literal 0
HcmV?d00001
diff --git a/src/__pycache__/config.cpython-312.pyc b/src/__pycache__/config.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..1d2b6afd8d99d64b544e141c09aa0278bbfd3d9c
GIT binary patch
literal 2812
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
Jb)r*u^aHx=b`}5t
literal 0
HcmV?d00001
diff --git a/src/adapters/__pycache__/base.cpython-312.pyc b/src/adapters/__pycache__/base.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..761cccaf5a1c471fb9e579da4bfcbd1bea95e643
GIT binary patch
literal 3390
zcmbVO&2JmW6`x)1l1nZr$<|ja%gs8r6S1*L)N1Rb37kr*>pHfXqHZoUgvDxiC~dtT
z%FHqei5`eX0d&-%K#n~dc7Pmu=>HG|2tXg?xx{Tzw7oG=i=;mFz1bB-$qLYB1^xIw
zX5Y-d&-t`cDG_)!KK#s&3WWR>nf*sk31#n(Kzu|z(j=bZseuxzO;tfU7vw^%sfBt|
zm%J9_!$PwV8cjp;dQc2Y%~EJKO@+{cO0E+xzeT*l12wh%Als{1&a?(-i_d5)nYIL4
z^BL`!v|9#Ug=!CSsmbPeXRJEjejT{mu}1^T@tn>$4x?-Y{VXj
zi=LA>Zs2e(^p;Npl;xJ9j;Jj0PUPNzot-3RZhFKNv-2u@3Gg2gN}39IT74V*+st`t
zQ}c3E_cZ(v;PR5wGp-=He8w4)D`Z?zat3lLp(ScQEIv@uEt_Sp1U607b;{L>Fz+Oc
zI^oJjJG$4cFIn7=*1;n#@>Yw*q2*ZcsjvhTRw(_+DBlEU(3&732TGxXmlH>nlQ<4+
z*9ijK_e4>0krPreCP{~_w?i5wTvS?qW@GS~V%)YzLI~1v
zkg^Gc69^{}paO`{a91`1g`Hj#r$7NyX6-%jFt(0bPw<JkFR
zo4troLy(cKBXuOm3n)XtpbUe2_Q}*t?-$!{Uz^#Tnd`l|lf$|_HQ&3rGY!@H*t+nE
z_sDt;iuXoed+pegMVp6$tbqU?WiJ9e7zj8Hs8%FJ7@>#0~ac-qLS
zJk`9Sr*CP)_X-`gS`uX$-lcR{cKK2^;zq_M&dJdk$!n#O1<5t+_$^I0z
z*5YJiWPU&kwGLIjl@3ragmxNk<^kyjyeNEt-oR#XlPKVkj`DHtJ*BF4&)ktS=}6y5
z#}={rex}
zn;scVkYf2id-(eAR89GxPhGoC6U%{u$-1Dd4{Lv%9&L)*XRt-s=EzZveG}d3Lx+pC@ka3NUR}7x6F+pIKg$Y*gSMkY-j0$DYOVY4(#T4n&a#tLRrW)$
zCNz|BG`#Q1vV1eE^X{o5X9S~}>gVtdKX7Xv-71e$|H=f}UJl<=QG4?LwJ>{}g|AdKv-%}c>3qTy
zH>wkIgyKc4)yAd`cdHyIM9x`r8CC~F4+wL3H>RBBQ7lJoKIv@2rBY64RzxvoLON4r
z&y~J8>@Q)w4n+fZzN_pyELPy3-vb!r6h(PVmj6ax_&YiG1zGrlTmV0*=GUsByfPpF
q;26~$NOndl3d+=g0Avber!ob+Q5e1f)W4?ADCM2Se-rqT4)`ysO-OeD
literal 0
HcmV?d00001
diff --git a/src/adapters/__pycache__/claude_adapter.cpython-312.pyc b/src/adapters/__pycache__/claude_adapter.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..37df90b986371536f0440b55ef9071ec09a8f3a8
GIT binary patch
literal 6959
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
O+85vamf%-g?|%VcryYC%
literal 0
HcmV?d00001
diff --git a/src/adapters/__pycache__/openai_adapter.cpython-312.pyc b/src/adapters/__pycache__/openai_adapter.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..502e05199af0425bbc39c8019e37325ddb53f6d4
GIT binary patch
literal 7370
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 AsyncIterator[StreamChunk]:
+ """Stream model response chunks."""
+ ...
+
+ @abstractmethod
+ async def complete(
+ self,
+ messages: list[dict[str, Any]],
+ tools: list[dict[str, Any]] | None = None,
+ config: ModelConfig | None = None,
+ ) -> ModelResponse:
+ """Get a complete model response (non-streaming)."""
+ ...
+
+ @abstractmethod
+ async def count_tokens(self, text: str) -> int:
+ """Estimate token count for the given text."""
+ ...
diff --git a/src/adapters/claude_adapter.py b/src/adapters/claude_adapter.py
new file mode 100644
index 0000000..5122880
--- /dev/null
+++ b/src/adapters/claude_adapter.py
@@ -0,0 +1,201 @@
+"""Claude/Anthropic model adapter with full streaming support."""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any, AsyncIterator
+
+import anthropic
+
+from ..config import settings
+from .base import ModelAdapter, ModelConfig, ModelResponse, StreamChunk
+
+logger = logging.getLogger(__name__)
+
+
+class ClaudeAdapter(ModelAdapter):
+ """Adapter for the Anthropic Claude API."""
+
+ def __init__(self, api_key: str | None = None) -> None:
+ self._client = anthropic.AsyncAnthropic(
+ api_key=api_key or settings.anthropic_api_key,
+ )
+
+ # ------------------------------------------------------------------
+ # Streaming
+ # ------------------------------------------------------------------
+
+ async def stream(
+ self,
+ messages: list[dict[str, Any]],
+ tools: list[dict[str, Any]] | None = None,
+ config: ModelConfig | None = None,
+ ) -> AsyncIterator[StreamChunk]:
+ config = config or ModelConfig(
+ model_id=settings.default_model_id,
+ max_tokens=settings.max_tokens,
+ temperature=settings.temperature,
+ )
+
+ # Separate system message
+ system_content = ""
+ api_messages: list[dict[str, Any]] = []
+ for m in messages:
+ if m["role"] == "system":
+ system_content = m["content"]
+ else:
+ api_messages.append(m)
+
+ kwargs: dict[str, Any] = {
+ "model": config.model_id or settings.default_model_id,
+ "max_tokens": config.max_tokens,
+ "temperature": config.temperature,
+ "messages": api_messages,
+ }
+ if system_content:
+ kwargs["system"] = system_content
+ if tools:
+ kwargs["tools"] = self._format_tools(tools)
+
+ async with self._client.messages.stream(**kwargs) as stream:
+ current_tool_id = ""
+ current_tool_name = ""
+ accumulated_args = ""
+
+ async for event in stream:
+ if event.type == "content_block_start":
+ block = event.content_block
+ if block.type == "tool_use":
+ current_tool_id = block.id
+ current_tool_name = block.name
+ accumulated_args = ""
+ yield StreamChunk(
+ tool_call_id=current_tool_id,
+ tool_name=current_tool_name,
+ )
+ continue
+
+ if event.type == "content_block_delta":
+ delta = event.delta
+ if delta.type == "text_delta":
+ yield StreamChunk(delta=delta.text)
+ elif delta.type == "input_json_delta":
+ accumulated_args += delta.partial_json
+ yield StreamChunk(
+ tool_call_id=current_tool_id,
+ tool_name=current_tool_name,
+ tool_arguments=delta.partial_json,
+ )
+ continue
+
+ if event.type == "content_block_stop":
+ if current_tool_id and accumulated_args:
+ yield StreamChunk(
+ tool_call_id=current_tool_id,
+ tool_name=current_tool_name,
+ tool_arguments=accumulated_args,
+ finish_reason="tool_use",
+ )
+ current_tool_id = ""
+ current_tool_name = ""
+ accumulated_args = ""
+ continue
+
+ if event.type == "message_delta":
+ yield StreamChunk(
+ finish_reason=event.delta.stop_reason or "",
+ usage={
+ "output_tokens": getattr(
+ event.usage, "output_tokens", 0
+ )
+ },
+ )
+
+ # ------------------------------------------------------------------
+ # Non-streaming
+ # ------------------------------------------------------------------
+
+ async def complete(
+ self,
+ messages: list[dict[str, Any]],
+ tools: list[dict[str, Any]] | None = None,
+ config: ModelConfig | None = None,
+ ) -> ModelResponse:
+ config = config or ModelConfig(
+ model_id=settings.default_model_id,
+ max_tokens=settings.max_tokens,
+ temperature=settings.temperature,
+ )
+
+ system_content = ""
+ api_messages: list[dict[str, Any]] = []
+ for m in messages:
+ if m["role"] == "system":
+ system_content = m["content"]
+ else:
+ api_messages.append(m)
+
+ kwargs: dict[str, Any] = {
+ "model": config.model_id or settings.default_model_id,
+ "max_tokens": config.max_tokens,
+ "temperature": config.temperature,
+ "messages": api_messages,
+ }
+ if system_content:
+ kwargs["system"] = system_content
+ if tools:
+ kwargs["tools"] = self._format_tools(tools)
+
+ response = await self._client.messages.create(**kwargs)
+
+ content = ""
+ tool_calls: list[dict[str, Any]] = []
+ for block in response.content:
+ if block.type == "text":
+ content += block.text
+ elif block.type == "tool_use":
+ tool_calls.append(
+ {
+ "id": block.id,
+ "name": block.name,
+ "arguments": block.input,
+ }
+ )
+
+ return ModelResponse(
+ content=content,
+ tool_calls=tool_calls,
+ finish_reason=response.stop_reason or "",
+ usage={
+ "input_tokens": response.usage.input_tokens,
+ "output_tokens": response.usage.output_tokens,
+ },
+ raw=response,
+ )
+
+ # ------------------------------------------------------------------
+ # Token counting
+ # ------------------------------------------------------------------
+
+ async def count_tokens(self, text: str) -> int:
+ from ..context.compactor import estimate_tokens
+ return estimate_tokens(text)
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _format_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Convert internal tool definitions to Anthropic tool format."""
+ formatted: list[dict[str, Any]] = []
+ for tool in tools:
+ formatted.append(
+ {
+ "name": tool["name"],
+ "description": tool.get("description", ""),
+ "input_schema": tool.get("input_schema", tool.get("parameters", {"type": "object"})),
+ }
+ )
+ return formatted
diff --git a/src/adapters/openai_adapter.py b/src/adapters/openai_adapter.py
new file mode 100644
index 0000000..355b408
--- /dev/null
+++ b/src/adapters/openai_adapter.py
@@ -0,0 +1,197 @@
+"""OpenAI model adapter with full streaming support."""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any, AsyncIterator
+
+from openai import AsyncOpenAI
+
+from ..config import settings
+from .base import ModelAdapter, ModelConfig, ModelResponse, StreamChunk
+
+logger = logging.getLogger(__name__)
+
+
+class OpenAIAdapter(ModelAdapter):
+ """Adapter for the OpenAI API (GPT-4o, o1, etc.)."""
+
+ def __init__(self, api_key: str | None = None) -> None:
+ self._client = AsyncOpenAI(
+ api_key=api_key or settings.openai_api_key,
+ )
+
+ # ------------------------------------------------------------------
+ # Streaming
+ # ------------------------------------------------------------------
+
+ async def stream(
+ self,
+ messages: list[dict[str, Any]],
+ tools: list[dict[str, Any]] | None = None,
+ config: ModelConfig | None = None,
+ ) -> AsyncIterator[StreamChunk]:
+ config = config or ModelConfig(
+ model_id=settings.default_model_id,
+ max_tokens=settings.max_tokens,
+ temperature=settings.temperature,
+ )
+
+ kwargs: dict[str, Any] = {
+ "model": config.model_id or "gpt-4o",
+ "max_tokens": config.max_tokens,
+ "temperature": config.temperature,
+ "messages": messages,
+ "stream": True,
+ }
+ if tools:
+ kwargs["tools"] = self._format_tools(tools)
+
+ stream = await self._client.chat.completions.create(**kwargs)
+
+ tool_calls_acc: dict[int, dict[str, str]] = {}
+
+ async for chunk in stream:
+ choice = chunk.choices[0] if chunk.choices else None
+ if not choice:
+ continue
+
+ delta = choice.delta
+
+ # Text content
+ if delta and delta.content:
+ yield StreamChunk(delta=delta.content)
+
+ # Tool calls
+ if delta and delta.tool_calls:
+ for tc in delta.tool_calls:
+ idx = tc.index
+ if idx not in tool_calls_acc:
+ tool_calls_acc[idx] = {
+ "id": tc.id or "",
+ "name": "",
+ "arguments": "",
+ }
+ if tc.id:
+ tool_calls_acc[idx]["id"] = tc.id
+ if tc.function and tc.function.name:
+ tool_calls_acc[idx]["name"] = tc.function.name
+ yield StreamChunk(
+ tool_call_id=tc.id or tool_calls_acc[idx]["id"],
+ tool_name=tc.function.name,
+ )
+ if tc.function and tc.function.arguments:
+ tool_calls_acc[idx]["arguments"] += tc.function.arguments
+ yield StreamChunk(
+ tool_call_id=tool_calls_acc[idx]["id"],
+ tool_name=tool_calls_acc[idx]["name"],
+ tool_arguments=tc.function.arguments,
+ )
+
+ # Finish
+ if choice.finish_reason:
+ if choice.finish_reason == "tool_calls":
+ for acc in tool_calls_acc.values():
+ yield StreamChunk(
+ tool_call_id=acc["id"],
+ tool_name=acc["name"],
+ tool_arguments=acc["arguments"],
+ finish_reason="tool_use",
+ )
+ else:
+ yield StreamChunk(
+ finish_reason="end_turn"
+ if choice.finish_reason == "stop"
+ else choice.finish_reason,
+ usage={
+ "output_tokens": chunk.usage.completion_tokens
+ if chunk.usage
+ else 0
+ },
+ )
+
+ # ------------------------------------------------------------------
+ # Non-streaming
+ # ------------------------------------------------------------------
+
+ async def complete(
+ self,
+ messages: list[dict[str, Any]],
+ tools: list[dict[str, Any]] | None = None,
+ config: ModelConfig | None = None,
+ ) -> ModelResponse:
+ config = config or ModelConfig(
+ model_id=settings.default_model_id,
+ max_tokens=settings.max_tokens,
+ temperature=settings.temperature,
+ )
+
+ kwargs: dict[str, Any] = {
+ "model": config.model_id or "gpt-4o",
+ "max_tokens": config.max_tokens,
+ "temperature": config.temperature,
+ "messages": messages,
+ }
+ if tools:
+ kwargs["tools"] = self._format_tools(tools)
+
+ response = await self._client.chat.completions.create(**kwargs)
+ choice = response.choices[0]
+
+ content = choice.message.content or ""
+ tool_calls: list[dict[str, Any]] = []
+
+ if choice.message.tool_calls:
+ for tc in choice.message.tool_calls:
+ tool_calls.append(
+ {
+ "id": tc.id,
+ "name": tc.function.name,
+ "arguments": json.loads(tc.function.arguments)
+ if tc.function.arguments
+ else {},
+ }
+ )
+
+ return ModelResponse(
+ content=content,
+ tool_calls=tool_calls,
+ finish_reason=choice.finish_reason or "",
+ usage={
+ "input_tokens": response.usage.prompt_tokens if response.usage else 0,
+ "output_tokens": response.usage.completion_tokens if response.usage else 0,
+ },
+ raw=response,
+ )
+
+ # ------------------------------------------------------------------
+ # Token counting
+ # ------------------------------------------------------------------
+
+ async def count_tokens(self, text: str) -> int:
+ from ..context.compactor import estimate_tokens
+ return estimate_tokens(text)
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _format_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Convert internal tool definitions to OpenAI function calling format."""
+ formatted: list[dict[str, Any]] = []
+ for tool in tools:
+ formatted.append(
+ {
+ "type": "function",
+ "function": {
+ "name": tool["name"],
+ "description": tool.get("description", ""),
+ "parameters": tool.get(
+ "input_schema", tool.get("parameters", {"type": "object"})
+ ),
+ },
+ }
+ )
+ return formatted
diff --git a/src/api/__init__.py b/src/api/__init__.py
new file mode 100644
index 0000000..1b2eccc
--- /dev/null
+++ b/src/api/__init__.py
@@ -0,0 +1,3 @@
+from .routes import router
+
+__all__ = ["router"]
diff --git a/src/api/__pycache__/__init__.cpython-312.pyc b/src/api/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..3502dc934d6708dc94d277a6db3ddd9b3729b26b
GIT binary patch
literal 238
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
literal 0
HcmV?d00001
diff --git a/src/api/routes.py b/src/api/routes.py
new file mode 100644
index 0000000..d2db28a
--- /dev/null
+++ b/src/api/routes.py
@@ -0,0 +1,375 @@
+"""REST API endpoints for the agentic microservice."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import pathlib
+from typing import Any
+
+from fastapi import APIRouter, HTTPException, Request
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel, Field
+
+from ..models.context import MemoryDocument, MemoryType
+from ..models.session import SessionState, SessionStatus
+from ..streaming.sse import EventType
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+# ------------------------------------------------------------------
+# Request / Response schemas
+# ------------------------------------------------------------------
+
+class CreateSessionRequest(BaseModel):
+ project_profile: dict[str, Any] = Field(default_factory=dict)
+ immutable_rules: list[str] = Field(default_factory=list)
+ metadata: dict[str, Any] = Field(default_factory=dict)
+
+
+class CreateSessionResponse(BaseModel):
+ session_id: str
+ status: str
+
+
+class SendMessageRequest(BaseModel):
+ message: str
+ stream: bool = False
+
+
+class SessionResponse(BaseModel):
+ session_id: str
+ status: str
+ turn_count: int
+ current_task: dict[str, Any] | None = None
+ completed_tasks: list[str] = Field(default_factory=list)
+ created_at: str
+ updated_at: str
+
+
+# ------------------------------------------------------------------
+# Dependency helpers (set by main.py at startup)
+# ------------------------------------------------------------------
+
+_deps: dict[str, Any] = {}
+
+
+def set_dependencies(
+ storage: Any,
+ orchestrator: Any,
+ sse_emitter: Any,
+ context_engine: Any = None,
+ memory_store: Any = None,
+) -> None:
+ _deps["storage"] = storage
+ _deps["orchestrator"] = orchestrator
+ _deps["sse"] = sse_emitter
+ if context_engine:
+ _deps["context_engine"] = context_engine
+ if memory_store:
+ _deps["memory_store"] = memory_store
+
+
+def _get_storage():
+ return _deps["storage"]
+
+
+def _get_orchestrator():
+ return _deps["orchestrator"]
+
+
+def _get_sse():
+ return _deps["sse"]
+
+
+# ------------------------------------------------------------------
+# POST /sessions
+# ------------------------------------------------------------------
+
+@router.post("/sessions", response_model=CreateSessionResponse, status_code=201)
+async def create_session(body: CreateSessionRequest) -> CreateSessionResponse:
+ storage = _get_storage()
+ session = SessionState(
+ project_profile=body.project_profile,
+ immutable_rules=body.immutable_rules,
+ metadata=body.metadata,
+ )
+ await storage.create_session(session)
+
+ sse = _get_sse()
+ await sse.emit(
+ EventType.SESSION_CREATED,
+ {"session_id": session.session_id},
+ session_id=session.session_id,
+ )
+
+ logger.info("Session created: %s", session.session_id)
+ return CreateSessionResponse(
+ session_id=session.session_id,
+ status=session.status.value,
+ )
+
+
+# ------------------------------------------------------------------
+# POST /sessions/{id}/messages
+# ------------------------------------------------------------------
+
+@router.post("/sessions/{session_id}/messages")
+async def send_message(
+ session_id: str, body: SendMessageRequest
+) -> dict[str, Any]:
+ storage = _get_storage()
+ session = await storage.get_session(session_id)
+ if not session:
+ raise HTTPException(status_code=404, detail="Session not found")
+
+ orchestrator = _get_orchestrator()
+
+ if body.stream:
+ asyncio.create_task(_execute_and_persist(orchestrator, storage, session, body.message))
+ return {
+ "session_id": session_id,
+ "status": "executing",
+ "stream_url": f"/sessions/{session_id}/stream",
+ }
+
+ result = await _execute_and_persist(orchestrator, storage, session, body.message)
+ return result
+
+
+async def _execute_and_persist(orchestrator, storage, session, message) -> dict[str, Any]:
+ # Acquire exclusive lock — prevents concurrent execution on same session
+ async with storage.session_lock(session.session_id) as acquired:
+ if not acquired:
+ return {
+ "session_id": session.session_id,
+ "content": "Error: session is busy — another request is executing",
+ "status": "busy",
+ }
+
+ try:
+ result = await orchestrator.process_message(session, message)
+ return result
+ except Exception as e:
+ session.status = SessionStatus.ERROR
+ logger.exception("Execution failed for session %s", session.session_id)
+ return {
+ "session_id": session.session_id,
+ "content": f"Error: {e}",
+ "status": "error",
+ }
+ finally:
+ try:
+ await storage.update_session(session)
+ except Exception as e:
+ logger.error("Failed to persist session state: %s", e)
+
+
+# ------------------------------------------------------------------
+# GET /sessions/{id}/stream
+# ------------------------------------------------------------------
+
+@router.get("/sessions/{session_id}/stream")
+async def stream_session(session_id: str) -> StreamingResponse:
+ storage = _get_storage()
+ session = await storage.get_session(session_id)
+ if not session:
+ raise HTTPException(status_code=404, detail="Session not found")
+
+ sse = _get_sse()
+
+ return StreamingResponse(
+ sse.subscribe(session_id),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ )
+
+
+# ------------------------------------------------------------------
+# GET /sessions/{id}
+# ------------------------------------------------------------------
+
+@router.get("/sessions/{session_id}", response_model=SessionResponse)
+async def get_session(session_id: str) -> SessionResponse:
+ storage = _get_storage()
+ session = await storage.get_session(session_id)
+ if not session:
+ raise HTTPException(status_code=404, detail="Session not found")
+
+ return SessionResponse(
+ session_id=session.session_id,
+ status=session.status.value,
+ turn_count=session.turn_count,
+ current_task=session.current_task.model_dump() if session.current_task else None,
+ completed_tasks=session.completed_tasks,
+ created_at=session.created_at.isoformat(),
+ updated_at=session.updated_at.isoformat(),
+ )
+
+
+# ------------------------------------------------------------------
+# DELETE /sessions/{id}
+# ------------------------------------------------------------------
+
+@router.delete("/sessions/{session_id}")
+async def delete_session(session_id: str) -> dict[str, str]:
+ storage = _get_storage()
+ deleted = await storage.delete_session(session_id)
+ if not deleted:
+ raise HTTPException(status_code=404, detail="Session not found")
+
+ sse = _get_sse()
+ sse.cleanup_session(session_id)
+
+ return {"status": "deleted", "session_id": session_id}
+
+
+# ------------------------------------------------------------------
+# GET /sessions/{id}/events
+# ------------------------------------------------------------------
+
+@router.get("/sessions/{session_id}/events")
+async def get_session_events(session_id: str) -> list[dict[str, Any]]:
+ sse = _get_sse()
+ return await sse.get_history(session_id)
+
+
+# ------------------------------------------------------------------
+# GET /sessions/{id}/context-debug
+# ------------------------------------------------------------------
+
+@router.get("/sessions/{session_id}/context-debug")
+async def get_context_debug(session_id: str) -> dict[str, Any]:
+ """Returns the full context engine debug history for a session.
+
+ Shows exactly what each agent received: sections, token counts,
+ priorities, compaction status, and content previews.
+ """
+ ctx_engine = _deps.get("context_engine")
+ if not ctx_engine:
+ raise HTTPException(status_code=501, detail="Context engine not available")
+
+ history = ctx_engine.get_debug_history(session_id)
+ last = ctx_engine.get_last_context_debug(session_id)
+
+ return {
+ "session_id": session_id,
+ "total_builds": len(history),
+ "last_build": last,
+ "history": history,
+ }
+
+
+# ------------------------------------------------------------------
+# Knowledge Base
+# ------------------------------------------------------------------
+
+class LoadKnowledgeRequest(BaseModel):
+ docs_path: str = "docs"
+
+
+@router.post("/knowledge/load")
+async def load_knowledge(body: LoadKnowledgeRequest) -> dict[str, Any]:
+ """Load markdown docs from a directory into the knowledge base."""
+ memory = _deps.get("memory_store")
+ if not memory:
+ raise HTTPException(status_code=501, detail="Memory store not available")
+
+ docs_dir = pathlib.Path(body.docs_path)
+ if not docs_dir.is_absolute():
+ # Resolve relative to project root
+ docs_dir = pathlib.Path(__file__).resolve().parent.parent.parent / body.docs_path
+
+ if not docs_dir.is_dir():
+ raise HTTPException(status_code=400, detail=f"Directory not found: {docs_dir}")
+
+ loaded = []
+ for md_file in sorted(docs_dir.glob("*.md")):
+ content = md_file.read_text(encoding="utf-8")
+ doc_id = md_file.stem
+
+ # Build a summary from the first ~500 chars
+ lines = content.strip().splitlines()
+ title = lines[0].lstrip("#").strip() if lines else doc_id
+ summary_lines = []
+ for line in lines[:30]:
+ line = line.strip()
+ if line and not line.startswith("#"):
+ summary_lines.append(line)
+ if len(" ".join(summary_lines)) > 500:
+ break
+ summary = " ".join(summary_lines)[:500]
+
+ # Extract tags from headings
+ tags = []
+ for line in lines:
+ if line.startswith("## "):
+ tags.append(line.lstrip("#").strip().lower()[:30])
+
+ doc = MemoryDocument(
+ memory_id=doc_id,
+ memory_type=MemoryType.DOCUMENT,
+ namespace="knowledge",
+ title=title,
+ content=content,
+ summary=summary,
+ tags=tags[:10],
+ )
+ await memory.store_document(doc)
+ loaded.append({
+ "id": doc_id,
+ "title": title,
+ "chars": len(content),
+ "tags": tags[:5],
+ })
+
+ logger.info("Loaded %d knowledge documents from %s", len(loaded), docs_dir)
+ return {
+ "status": "loaded",
+ "count": len(loaded),
+ "documents": loaded,
+ }
+
+
+@router.get("/knowledge")
+async def list_knowledge() -> dict[str, Any]:
+ """List all documents in the knowledge base."""
+ memory = _deps.get("memory_store")
+ if not memory:
+ raise HTTPException(status_code=501, detail="Memory store not available")
+
+ docs = await memory.list_documents(namespace="knowledge")
+ return {
+ "count": len(docs),
+ "documents": [
+ {
+ "id": d.memory_id,
+ "title": d.title,
+ "chars": len(d.content),
+ "summary": d.summary[:200],
+ "tags": d.tags,
+ "updated_at": d.updated_at.isoformat(),
+ }
+ for d in docs
+ ],
+ }
+
+
+@router.delete("/knowledge/{doc_id}")
+async def delete_knowledge(doc_id: str) -> dict[str, str]:
+ """Remove a document from the knowledge base."""
+ memory = _deps.get("memory_store")
+ if not memory:
+ raise HTTPException(status_code=501, detail="Memory store not available")
+
+ deleted = await memory.delete_document(doc_id, namespace="knowledge")
+ if not deleted:
+ raise HTTPException(status_code=404, detail="Document not found")
+ return {"status": "deleted", "id": doc_id}
diff --git a/src/config.py b/src/config.py
new file mode 100644
index 0000000..d8e7f00
--- /dev/null
+++ b/src/config.py
@@ -0,0 +1,61 @@
+"""Application configuration via environment variables."""
+
+from __future__ import annotations
+
+from pydantic_settings import BaseSettings
+from pydantic import Field
+
+
+class Settings(BaseSettings):
+ # --- Service ---
+ service_name: str = "agentic-microservice"
+ service_version: str = "1.0.0"
+ host: str = "0.0.0.0"
+ port: int = 8000
+ debug: bool = False
+
+ # --- Redis ---
+ redis_host: str = "localhost"
+ redis_port: int = 6379
+ redis_db: int = 0
+ redis_password: str = ""
+ redis_key_prefix: str = "agentic"
+ session_ttl_seconds: int = 86400 # 24h
+
+ @property
+ def redis_url(self) -> str:
+ auth = f":{self.redis_password}@" if self.redis_password else ""
+ return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
+
+ # --- Model providers ---
+ anthropic_api_key: str = ""
+ openai_api_key: str = ""
+ default_model_provider: str = "claude"
+ default_model_id: str = "claude-sonnet-4-20250514"
+ max_tokens: int = 4096
+ temperature: float = 0.3
+
+ # --- Context engine ---
+ context_max_tokens: int = 120_000
+ compaction_threshold_tokens: int = 80_000
+ artifact_summary_max_chars: int = 2000
+ working_context_max_items: int = 20
+
+ # --- MCP ---
+ mcp_server_command: str = ""
+ mcp_server_args: list[str] = Field(default_factory=list)
+ mcp_timeout_seconds: float = 30.0
+ mcp_startup_timeout_seconds: float = 10.0
+
+ # --- Orchestrator ---
+ max_execution_steps: int = 25
+ subagent_max_steps: int = 10
+ max_execution_timeout_seconds: float = 300.0 # 5 min global timeout
+
+ # --- SSE ---
+ sse_keepalive_seconds: float = 15.0
+
+ model_config = {"env_prefix": "AGENTIC_", "env_file": ".env", "extra": "ignore"}
+
+
+settings = Settings()
diff --git a/src/context/__init__.py b/src/context/__init__.py
new file mode 100644
index 0000000..9d55d4a
--- /dev/null
+++ b/src/context/__init__.py
@@ -0,0 +1,4 @@
+from .engine import ContextEngine
+from .compactor import ContextCompactor
+
+__all__ = ["ContextEngine", "ContextCompactor"]
diff --git a/src/context/__pycache__/__init__.cpython-312.pyc b/src/context/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..7559ba60b29f93c467ff787160156245a572aea4
GIT binary patch
literal 311
zcmX|6Jxc>Y5Z%4IBqrg&%0g@eL9WUE0kMg;X~gE5W!YVhmHjw&H{fYy;cxIa_*+Cs
zeGm|=?1Xey&Rvd7@#eiZk74E`&u6Hs*H85f?YkEPBtzd_0C__K39OO9CB_|3bYjvn
z1)hqmO3JJm72_Wa(E_*m(%C2<~+{Z$<8d^npA$FPIgIJ&_y7*GAeHNbOzanPFTxOIQV-;&)Am(vaXe
z;rqJ?rOK*E>7s7hqbuXYTFYxchxOeD;RN6}#TakV`4(M%%}+OHi_MI@O#Bo^{|8J~
A2><{9
literal 0
HcmV?d00001
diff --git a/src/context/__pycache__/compactor.cpython-312.pyc b/src/context/__pycache__/compactor.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..720b58a20d682092fe697e9f427f578d29e74fb6
GIT binary patch
literal 11322
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
literal 0
HcmV?d00001
diff --git a/src/context/__pycache__/engine.cpython-312.pyc b/src/context/__pycache__/engine.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..a6afedcdb1a6d0300a2f896b714507dfd878b13c
GIT binary patch
literal 22637
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