diff --git a/mcp-server/auth/localClient.js b/mcp-server/auth/localClient.js index a3acee9..6063883 100644 --- a/mcp-server/auth/localClient.js +++ b/mcp-server/auth/localClient.js @@ -11,10 +11,15 @@ import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../config/index.js"; * automaticamente; en modo stdio no se propaga y la logica original se * mantiene. */ -export async function fetchProjectInfo(projectName, acaiUser = null) { +export async function fetchProjectInfo(projectName, acaiUser = null, opts = {}) { const params = typeof projectName === "string" ? { project: projectName } : (projectName || {}); const headers = getLocalServerHeaders(); if (acaiUser) headers["X-Acai-User"] = acaiUser; + // forceMode: fuerza el modo efectivo con el que el server Python resuelve el + // web_url/api_web_url del proyecto. Lo usa el transporte MCP HTTP (plugin VS + // Code) para fijar "local" → la sesion entera apunta al web forge-local + // (test), nunca a produccion, sea cual sea el mode del .acai. + if (opts.forceMode) headers["X-Acai-Mode"] = opts.forceMode; const response = await axios.get(`${LOCAL_SERVER_URL}/api/project-info`, { params, headers, diff --git a/mcp-server/httpServer.js b/mcp-server/httpServer.js index 375b266..8ae6daa 100644 --- a/mcp-server/httpServer.js +++ b/mcp-server/httpServer.js @@ -76,7 +76,12 @@ const verifyJwt = (token) => { const resolveProjectCredentials = async (projectName, acaiUser = null) => { try { - const info = await fetchProjectInfo(projectName, acaiUser); + // El transporte MCP HTTP es exclusivo de clientes externos (plugin VS + // Code Acai Forge). Por politica solo pueden operar sobre TEST: forzamos + // mode=local al resolver el proyecto, de modo que web_url/api_web_url + // apunten al web forge-local y TODA la sesion (records, modules, git, + // media...) use el destino de test, nunca produccion. + const info = await fetchProjectInfo(projectName, acaiUser, { forceMode: "local" }); if (!info.success) { throw new Error(info.error || "Failed to resolve project info"); } diff --git a/mcp-server/tools/files/helpers.js b/mcp-server/tools/files/helpers.js index 1088d6d..2dd7c29 100644 --- a/mcp-server/tools/files/helpers.js +++ b/mcp-server/tools/files/helpers.js @@ -3,7 +3,7 @@ import path from "path"; import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../../config/index.js"; import { getCurrentSessionId } from "../../utils/sessionContext.js"; import { getMcpSessionCredentials } from "../../auth/credentials.js"; -import { resolveCurrentAcaiUser } from "../helpers/sessionHelpers.js"; +import { resolveCurrentAcaiUser, resolveCurrentModeOverride } from "../helpers/sessionHelpers.js"; /** * Resuelve `project_dir` para la tool en curso. @@ -38,7 +38,7 @@ export function getCurrentProjectInfo() { export async function callLocalFileEndpoint(method, endpoint, payload = null, query = null) { const headers = getLocalServerHeaders(); const authHeader = process.env.ACAI_AUTH_HEADER || ""; - const mode = process.env.ACAI_MODE_OVERRIDE || process.env.ACAI_MODE || ""; + const mode = resolveCurrentModeOverride(); const role = process.env.ACAI_ROLE_OVERRIDE || ""; if (authHeader) headers["Authorization"] = authHeader; diff --git a/mcp-server/tools/helpers/pythonServerClient.js b/mcp-server/tools/helpers/pythonServerClient.js index 9ab8ef1..cd14050 100644 --- a/mcp-server/tools/helpers/pythonServerClient.js +++ b/mcp-server/tools/helpers/pythonServerClient.js @@ -1,5 +1,5 @@ import axios from "axios"; -import { resolveCurrentAcaiUser } from "./sessionHelpers.js"; +import { resolveCurrentAcaiUser, resolveCurrentModeOverride } from "./sessionHelpers.js"; const PYTHON_BASE = `http://app:${process.env.ACAI_PORT || 9091}`; @@ -11,7 +11,7 @@ const PYTHON_BASE = `http://app:${process.env.ACAI_PORT || 9091}`; */ function buildPythonHeaders(extra = {}) { const authHeader = process.env.ACAI_AUTH_HEADER || ""; - const mode = process.env.ACAI_MODE_OVERRIDE || process.env.ACAI_MODE || ""; + const mode = resolveCurrentModeOverride(); const role = process.env.ACAI_ROLE_OVERRIDE || ""; const acaiUser = resolveCurrentAcaiUser(); @@ -43,3 +43,20 @@ export async function pythonGet(path, params = null, timeout = 30000) { }); return response.data; } + +/** + * GET binario al server Python (p.ej. /api/image-bytes). Devuelve + * { buffer: Buffer, mimeType: string }. Lanza si el status no es 2xx. + */ +export async function pythonGetBinary(path, params = null, timeout = 30000) { + const response = await axios.get(`${PYTHON_BASE}${path}`, { + params: params || undefined, + headers: buildPythonHeaders(), + responseType: "arraybuffer", + timeout, + maxContentLength: Infinity, + }); + const mimeType = (response.headers?.["content-type"] || "").split(";")[0].trim() + || "application/octet-stream"; + return { buffer: Buffer.from(response.data), mimeType }; +} diff --git a/mcp-server/tools/helpers/sessionHelpers.js b/mcp-server/tools/helpers/sessionHelpers.js index b93ae63..04280d0 100644 --- a/mcp-server/tools/helpers/sessionHelpers.js +++ b/mcp-server/tools/helpers/sessionHelpers.js @@ -25,3 +25,23 @@ export function resolveCurrentAcaiUser() { const creds = getMcpSessionCredentials(sessionId); return creds?.acai_user || null; } + +/** + * Modo efectivo (X-Acai-Mode) para las llamadas al server Python. + * + * Regla de seguridad: una sesion MCP HTTP (mcpSessionId presente) es SIEMPRE un + * cliente externo — en la practica el plugin VS Code Acai Forge — y solo puede + * operar sobre TEST. Por eso forzamos "local" pase lo que pase el .acai del + * proyecto. El server Python honra este header para decidir el destino real + * (BD y ficheros), de modo que vscode nunca toca produccion. + * + * Las sesiones stdio (chat del dashboard / cronjobs) NO tienen mcpSessionId: + * mantienen el override de entorno (ACAI_MODE_OVERRIDE), que puede ser + * "production" cuando corresponde (chat en modo produccion, cron de prod). + * + * @returns {string} "local" | "production" | "" (vacio = usar .acai) + */ +export function resolveCurrentModeOverride() { + if (getCurrentSessionId()) return "local"; + return process.env.ACAI_MODE_OVERRIDE || process.env.ACAI_MODE || ""; +} diff --git a/mcp-server/tools/media/analyze_image.js b/mcp-server/tools/media/analyze_image.js index 9386dcd..daf05c6 100644 --- a/mcp-server/tools/media/analyze_image.js +++ b/mcp-server/tools/media/analyze_image.js @@ -1,13 +1,38 @@ import { z } from "zod"; import axios from "axios"; -import fs from "fs"; import path from "path"; import { withAuth } from "../../auth/index.js"; import { handleToolError } from "../helpers/errorHandler.js"; import { withAuthParams } from "../helpers/authSchema.js"; +import { resolveCurrentProjectDir } from "../files/helpers.js"; +import { pythonGetBinary } from "../helpers/pythonServerClient.js"; const GEMINI_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent"; -const CHAT_UPLOADS_DIR = "/opt/acai/chat-uploads"; +// Hosts locales NO alcanzables por HTTP desde este container (localhost = el +// propio agentic). Para esas refs y rutas del proyecto, los bytes los resuelve +// el server Python (/api/image-bytes), que decide disco (standalone) vs fetch de +// producción (Acai / stub local). +const LOCAL_HOSTS = ["localhost", "127.0.0.1", "acai-app", "acai-web", "web", "host.docker.internal"]; +// Dominio forge del entorno (forge.acai.test en local, forge.acaisuite.com en +// prod). Los hosts forge resuelven (dnsmasq) a 127.0.0.1, que dentro de este +// container es él mismo → un fetch directo da ECONNREFUSED, sea http o https. +// Por eso NO son "remotos": sus bytes los resuelve el server Python +// (/api/image-bytes), que para un proyecto Acai trae el stub desde producción. +// Env-driven, igual que is_local_project_host en Python. +const FORGE_DOMAIN = (process.env.ACAI_FORGE_DOMAIN || "forge.acaisuite.com").toLowerCase(); + +/** + * ¿Es un host que debe resolverse vía el server Python (no alcanzable / no + * conviene un fetch directo desde este container)? Cubre loopback/hosts Docker + * internos y el dominio forge del entorno. + */ +function isLocalResolvableHost(hostname) { + if (!hostname) return false; + const h = hostname.toLowerCase(); + if (LOCAL_HOSTS.includes(h)) return true; + if (FORGE_DOMAIN && (h === FORGE_DOMAIN || h.endsWith("." + FORGE_DOMAIN))) return true; + return false; +} const DEFAULT_PROMPT = "Describe esta imagen detalladamente, mencionando elementos visuales, texto, layout y proposito aparente."; /** @@ -38,56 +63,20 @@ function detectMimeType(filename, buffer) { } /** - * Resuelve una URL de chat-preview a una ruta local segura dentro de CHAT_UPLOADS_DIR. - * Acepta `/api/chat-preview?file=xxx` o variantes con host. - */ -function resolveChatPreviewPath(imageUrl) { - let qs; - try { - // Permite tanto absolutas como relativas - const u = imageUrl.startsWith("http") - ? new URL(imageUrl) - : new URL(imageUrl, "http://placeholder.local"); - if (!u.pathname.startsWith("/api/chat-preview")) return null; - qs = u.searchParams; - } catch { - return null; - } - - const fileParam = qs.get("file"); - if (!fileParam) return null; - - // Sanitizar contra traversal PRESERVANDO el subdirectorio de usuario - // (el file= es "/"; basename lo perdía → not found). - if (fileParam.includes("..") || fileParam.startsWith("/") || fileParam.includes("\\")) return null; - - const fullPath = path.join(CHAT_UPLOADS_DIR, fileParam); - // Asegurar que queda dentro de CHAT_UPLOADS_DIR. - const base = path.resolve(CHAT_UPLOADS_DIR); - if (!path.resolve(fullPath).startsWith(base + path.sep)) return null; - - return fullPath; -} - -/** - * Carga la imagen como { mimeType, base64 } desde URL publica o chat-preview local. + * Carga la imagen como { mimeType, base64 }. + * - URL remota real (host público) → fetch directo por HTTP. + * - Adjunto de chat, ruta del proyecto, o URL con host local → los bytes los + * resuelve el server Python (/api/image-bytes): disco para standalone, fetch + * de producción para imágenes Acai cuyo fichero local es un stub. */ async function loadImage(imageUrl) { - // Caso 1: chat-preview local - const localPath = resolveChatPreviewPath(imageUrl); - if (localPath) { - if (!fs.existsSync(localPath)) { - throw new Error(`Local chat upload not found: ${path.basename(localPath)}`); - } - const buffer = fs.readFileSync(localPath); - return { - mimeType: detectMimeType(localPath, buffer), - base64: buffer.toString("base64"), - }; - } + let parsed = null; + try { parsed = new URL(imageUrl); } catch { parsed = null; } + const isRemote = parsed + && (parsed.protocol === "http:" || parsed.protocol === "https:") + && parsed.hostname && !isLocalResolvableHost(parsed.hostname); - // Caso 2: URL publica http(s) - if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) { + if (isRemote) { const response = await axios.get(imageUrl, { responseType: "arraybuffer", timeout: 30000, @@ -98,19 +87,21 @@ async function loadImage(imageUrl) { const mimeType = headerMime && headerMime.startsWith("image/") ? headerMime : detectMimeType(imageUrl.split("?")[0], buffer); - return { - mimeType, - base64: buffer.toString("base64"), - }; + return { mimeType, base64: buffer.toString("base64") }; } - throw new Error("Unsupported image_url. Use http(s):// or /api/chat-preview?file=..."); + const project = path.basename(resolveCurrentProjectDir() || ""); + if (!project) { + throw new Error("No hay proyecto activo para resolver la imagen."); + } + const { buffer, mimeType } = await pythonGetBinary("/api/image-bytes", { project, ref: imageUrl }); + return { mimeType, base64: buffer.toString("base64") }; } export function registerAnalyzeImageTool(server) { server.tool( "analyze_image", - "Analiza una imagen usando Gemini Vision. Util cuando el usuario adjunta una imagen, despues de un screenshot de Playwright, o para describir cualquier imagen accesible via URL. Devuelve descripcion text del contenido visual.", + "Analiza una imagen usando Gemini Vision. Usala SOLO para imagenes que NO puedes ver directamente (p.ej. una URL/imagen del CMS que no esta adjunta a la conversacion, o un screenshot de Playwright). Si la imagen ya esta adjunta y visible en el mensaje del usuario, descríbela tú mismo SIN llamar a esta tool. Devuelve descripcion text del contenido visual.", withAuthParams({ image_url: z.string().describe("URL de la imagen. Acepta URL publica http(s):// o ruta relativa /api/chat-preview?file=..."), prompt: z.string().optional().describe("Que quieres saber de la imagen. Default: descripcion detallada."), diff --git a/mcp-server/tools/media/upload.js b/mcp-server/tools/media/upload.js index 2f6dbd3..121fcea 100644 --- a/mcp-server/tools/media/upload.js +++ b/mcp-server/tools/media/upload.js @@ -5,7 +5,7 @@ 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"; -import { pythonPost } from "../helpers/pythonServerClient.js"; +import { pythonPost, pythonGetBinary } from "../helpers/pythonServerClient.js"; import { resolveCurrentProjectDir } from "../files/helpers.js"; /** @@ -34,68 +34,32 @@ async function mcpPost(target, actionWs, payload, token, tokenHash) { * null si la URL no es local (usar imageUrl directamente) */ async function resolveLocalImageAsBase64(imageUrl) { - const LOCAL_HOSTS = ["localhost", "127.0.0.1", "acai-app", "host.docker.internal"]; + const LOCAL_HOSTS = ["localhost", "127.0.0.1", "acai-app", "acai-web", "web", "host.docker.internal"]; - // Caso 1: Path absoluto del filesystem (e.g. /opt/acai/webs/.../cms/uploads/x.jpg) - if (typeof imageUrl === "string" && imageUrl.startsWith("/") && !imageUrl.startsWith("//")) { - try { - if (fs.existsSync(imageUrl) && fs.statSync(imageUrl).isFile()) { - const buffer = fs.readFileSync(imageUrl); - return { - fileBase64: buffer.toString("base64"), - fileName: path.basename(imageUrl), - }; - } - } catch (error) { - console.error(`[upload] Failed to read filesystem path ${imageUrl}: ${error.message}`); - } - return null; + // URL http(s) con host NO local → es una URL pública real: usar tal cual (null). + if (typeof imageUrl === "string" && /^https?:\/\//i.test(imageUrl)) { + let parsed; + try { parsed = new URL(imageUrl); } catch { return null; } + if (!LOCAL_HOSTS.includes(parsed.hostname)) return null; } - // Caso 2: URL HTTP — verificar si es local - let parsed; + // Ruta del proyecto, host local o chat-preview → los bytes los resuelve el + // server Python (/api/image-bytes): disco para standalone, fetch de producción + // para imágenes Acai cuyo fichero local es un stub. + const project = path.basename(resolveCurrentProjectDir() || ""); + if (!project) return null; try { - parsed = new URL(imageUrl); - } catch { - return null; - } - if (!LOCAL_HOSTS.includes(parsed.hostname)) { - return null; - } - - // Intento A: descargar via HTTP (funciona cuando el host local es alcanzable) - try { - const axios = (await import("axios")).default; - const response = await axios.get(imageUrl, { - responseType: "arraybuffer", - timeout: 30000, - }); - const fileBase64 = Buffer.from(response.data).toString("base64"); - const pathname = parsed.pathname || "/image.jpg"; - const fileName = pathname.split("/").pop() || "image.jpg"; - return { fileBase64, fileName }; - } catch (httpError) { - console.error(`[upload] HTTP fetch failed for ${imageUrl}: ${httpError.message}. Trying filesystem fallback.`); - } - - // Intento B: resolver el pathname contra ACAI_PROJECT_DIR y leer del disco - const projectDir = resolveCurrentProjectDir(); - if (projectDir && parsed.pathname) { + const { buffer } = await pythonGetBinary("/api/image-bytes", { project, ref: imageUrl }); + let fileName = "image.jpg"; try { - const localPath = path.join(projectDir, parsed.pathname); - if (fs.existsSync(localPath) && fs.statSync(localPath).isFile()) { - const buffer = fs.readFileSync(localPath); - return { - fileBase64: buffer.toString("base64"), - fileName: path.basename(localPath), - }; - } - } catch (error) { - console.error(`[upload] Filesystem fallback failed for ${imageUrl}: ${error.message}`); - } + const p = imageUrl.startsWith("/") ? imageUrl : new URL(imageUrl).pathname; + fileName = (p.split("?")[0].split("/").pop()) || "image.jpg"; + } catch { /* keep default */ } + return { fileBase64: buffer.toString("base64"), fileName }; + } catch (error) { + console.error(`[upload] /api/image-bytes falló para ${imageUrl}: ${error.message}`); + return null; } - - return null; } export function registerUploadRecordImageTool(server) {