import fs from "node:fs"; import path from "node:path"; 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"; import { pythonPost } from "../helpers/pythonServerClient.js"; import { resolveCurrentProjectDir } from "../files/helpers.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 ); } /** * Si imageUrl apunta a un host local (localhost, 127.0.0.1, acai-app), * descarga el archivo y lo retorna como base64 para incluirlo en el payload. * Esto permite subir imagenes locales a un servidor remoto (modo produccion), * ya que el servidor remoto no tiene acceso a nuestras URLs locales. * * @param {string} imageUrl * @returns {Promise<{fileBase64: string, fileName: string} | null>} * 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"]; // 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; } // Caso 2: URL HTTP — verificar si es local let parsed; 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) { 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}`); } } return null; } export function registerUploadRecordImageTool(server) { server.tool( "upload_record_image", "Upload an image to a specific record field in Acai CMS. MANDATORY: before calling this tool, you MUST call get_table_schema with minimal=true to find the EXACT upload field name. Look for fields with type='upload'. NEVER guess field names. Table names WITHOUT 'cms_' prefix. recordId is 'num', never 'id'. If the URL came from generate_image, prefer uploadUrl (or fullUrl) over dockerUrl.", 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("EXACT field name from the schema. MUST match a field with type 'upload' from get_table_schema or get_module_config_vars. Do NOT guess."), 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 projectSlug = path.basename(resolveCurrentProjectDir()); // Intentar via Python server (tiene sync + optimizacion) let result; try { result = await pythonPost("/api/cms/upload-to-field", { project: projectSlug, table: tableName, num: recordId, field: fieldName, imageUrl: imageUrl, }); } catch (pyErr) { // Fallback: si Python no es accesible, usar el flujo directo al PHP console.error(`[upload_record_image] Python server failed (${pyErr.message}), falling back to PHP direct`); const credentials = await getSessionCredentials(extra.sessionId); const localFile = await resolveLocalImageAsBase64(imageUrl); const uploadPayload = localFile ? { tableName, recordId, fieldName, alt, fileBase64: localFile.fileBase64, fileName: localFile.fileName } : { tableName, recordId, fieldName, imageUrl, alt }; const response = await mcpPost(credentials, "uploadRecordImage", uploadPayload, credentials.token, credentials.tokenHash); const apiError = handleApiResponse(response.data, 'upload_record_image'); if (apiError) return apiError; result = { success: true, data: response.data }; } if (!result?.success && !result?.data?.success) { const errMsg = result?.error || result?.data?.error || "Unknown error"; return { content: [{ type: "text", text: JSON.stringify({ error: errMsg }, null, 2) }], isError: true }; } const uploadData = result.data || result; return { content: [{ type: "text", text: JSON.stringify({ success: true, ...uploadData, }, 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 // Si la URL es local, descargar y enviar base64 (el servidor remoto no // puede descargarla en modo produccion). const localFile = await resolveLocalImageAsBase64(imageUrl); const uploadPayload = localFile ? { tableName, recordId, fieldName, alt, fileBase64: localFile.fileBase64, fileName: localFile.fileName } : { tableName, recordId, fieldName, imageUrl, alt }; const response = await mcpPost( credentials, "uploadRecordImage", uploadPayload, 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 }); } }) ); }