Files
agenticSystem/mcp-server/tools/media/upload.js
Jordan 9d11a59fb8 upload_record_image: aceptar ruta relativa del proyecto (sin base64)
Para una imagen local/pegada desde vscode: guardarla en una carpeta
sincronizada NO truncada (cms/uploads/chat/ o cms/uploads/generated/),
dejar que el sync la suba a test y pasar su RUTA RELATIVA como imageUrl.
El server lee los bytes de disco vía resolve_image_source — cero base64
por el contexto del modelo, cero URLs localhost inalcanzables.

- Validación relajada: además de http(s) y ruta absoluta, se acepta ruta
  relativa del proyecto (sin esquema, sin "..", <=512 chars, charset de
  ruta) → sigue rechazando data-URI/base64 crudo.
- Descripciones de upload_record_image / replace_record_image actualizadas
  con el flujo correcto.
- resolve_image_source y el aislamiento de entorno: sin cambios (la ruta
  relativa la resuelve por modo+stub, igual para chat y vscode).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:14:16 +01:00

416 lines
20 KiB
JavaScript

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, pythonGetBinary } 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", "acai-web", "web", "host.docker.internal"];
// 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;
}
// 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 {
const { buffer } = await pythonGetBinary("/api/image-bytes", { project, ref: imageUrl });
let fileName = "image.jpg";
try {
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;
}
}
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. For a LOCAL or pasted image (a file on your machine, no public URL): save it into the synced project folder cms/uploads/chat/<name>.ext, wait for the sync to push it, then pass its PROJECT-RELATIVE path (e.g. 'cms/uploads/chat/foto.png') as imageUrl. NEVER pass a data-URI/base64 nor spin up a localhost server.",
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("Image to upload: an http(s) URL, OR a project-relative path to a file already synced to the project (e.g. 'cms/uploads/chat/foto.png'). For local/pasted images use the relative-path form. NOT a data-URI or base64."),
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;
// Aceptamos: URL http(s), ruta absoluta del servidor, o RUTA
// RELATIVA del proyecto (p.ej. "cms/uploads/chat/foto.png"). Para
// una imagen local/pegada el flujo correcto es guardarla en una
// carpeta sincronizada NO truncada (cms/uploads/chat/ o
// cms/uploads/generated/), dejar que el sync la suba a test y pasar
// aquí su ruta relativa: el server lee los bytes de disco vía
// resolve_image_source (sin base64 por el modelo).
// Seguimos rechazando data-URI / base64 crudo: derivar el nombre
// de un base64 gigante revienta file_put_contents ("File name too
// long"). El tope de longitud + charset de ruta lo descartan.
const trimmedImage = imageUrl.trim();
const isHttpUrl = /^https?:\/\//i.test(trimmedImage);
const isAbsPath = trimmedImage.startsWith("/") && !trimmedImage.startsWith("//");
const isRelPath = !isHttpUrl && !isAbsPath
&& !/^[a-z][a-z0-9+.-]*:/i.test(trimmedImage) // sin esquema (data:, file:...)
&& !trimmedImage.includes("..")
&& trimmedImage.length <= 512
&& /^[\w./ -]+$/.test(trimmedImage); // charset de ruta (no base64)
if (!isHttpUrl && !isAbsPath && !isRelPath) {
return {
content: [{
type: "text",
text: JSON.stringify({
error: "imageUrl debe ser una URL http(s) o una ruta relativa del proyecto " +
"(p.ej. 'cms/uploads/chat/foto.png'), no un data-URI ni base64 crudo. " +
"Para una imagen local/pegada: guárdala en cms/uploads/chat/ (sincronizada a test), " +
"espera a que el sync la suba y pasa su ruta relativa."
}, null, 2)
}],
isError: true,
};
}
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("New image: an http(s) URL, OR a project-relative path to a file already synced (e.g. 'cms/uploads/chat/foto.png'). For local/pasted images use the relative-path form. NOT a data-URI or base64."),
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 projectSlug = path.basename(resolveCurrentProjectDir());
let result;
try {
// Paso 1: borrar upload viejo via Python (incluye sync de borrado a produccion).
await pythonPost("/api/cms/delete-upload", {
project: projectSlug,
uploadId,
table: tableName,
});
// Paso 2: subir el nuevo via Python (incluye sync a produccion + truncado local).
result = await pythonPost("/api/cms/upload-to-field", {
project: projectSlug,
table: tableName,
num: recordId,
field: fieldName,
imageUrl: imageUrl,
});
} catch (pyErr) {
// Fallback al flujo directo PHP solo cuando el Python no es accesible.
// En este path queda el bug original (sin sync a produccion en modo test),
// pero es preferible a fallar el tool entero.
console.error(`[replace_record_image] Python server failed (${pyErr.message}), falling back to PHP direct`);
const credentials = await getSessionCredentials(extra.sessionId);
await mcpPost(
credentials,
"deleteRecordUpload",
{ uploadId },
credentials.token,
credentials.tokenHash
);
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;
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 replaceData = result.data || result;
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
message: "Image replaced successfully",
tableName,
recordId,
fieldName,
replacedUploadId: uploadId,
...replaceData,
}, 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 });
}
})
);
}