Imágenes: - analyze_image y upload resuelven los bytes por el endpoint Python /api/image-bytes (pythonGetBinary). analyze_image enruta los dominios forge (env ACAI_FORGE_DOMAIN) al endpoint en vez de fetch directo (que daba ECONNREFUSED 127.0.0.1 dentro del container). Aislamiento de entorno (vscode = solo test): - resolveCurrentModeOverride(): sesión MCP HTTP (mcpSessionId presente) → "local"; stdio (chat/cron) → ACAI_MODE_OVERRIDE de entorno. Lo usan los builders de headers (pythonServerClient, files/helpers) → toda tool del MCP HTTP manda X-Acai-Mode: local. - httpServer.resolveProjectCredentials fuerza forceMode:"local" al resolver project-info → la sesión obtiene web_url/api_web_url forge-local y opera siempre contra test, nunca producción. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
402 lines
18 KiB
JavaScript
402 lines
18 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.",
|
|
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;
|
|
|
|
// Rechazar data-URI / base64 crudo: derivar el nombre de fichero
|
|
// del base64 produce nombres de miles de chars que revientan
|
|
// file_put_contents ("File name too long"). Exigir URL http real.
|
|
const trimmedImage = imageUrl.trim();
|
|
const isHttpUrl = /^https?:\/\//i.test(trimmedImage);
|
|
const isFsPath = trimmedImage.startsWith("/") && !trimmedImage.startsWith("//");
|
|
if (!isHttpUrl && !isFsPath) {
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: JSON.stringify({
|
|
error: "imageUrl must be an http(s) URL, not a data URI or raw base64. " +
|
|
"First upload the image with the 'upload_image_to_assets' tool and pass the returned imageUrl here."
|
|
}, 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("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 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 });
|
|
}
|
|
})
|
|
);
|
|
}
|