cambios mcp remoto
This commit is contained in:
@@ -1,97 +1,90 @@
|
||||
import { z } from "zod";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { sessionCredentials } from "../../auth/credentials.js";
|
||||
import { sessionCredentials, setMcpSessionCredentials } from "../../auth/credentials.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
import { fetchProjectInfo } from "../../auth/localClient.js";
|
||||
import { resolveCurrentProjectDir } from "../files/helpers.js";
|
||||
import { resolveCurrentAcaiUser } from "../helpers/sessionHelpers.js";
|
||||
import { getCurrentSessionId } from "../../utils/sessionContext.js";
|
||||
|
||||
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.`,
|
||||
`Refresh the Acai JWT token when it has expired (403 "Token no válido" errors). Delegates to the Python server which detects expiration, renews the token against the webservice if needed, persists the updated .acai file and returns fresh credentials. 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) {
|
||||
const projectDir = resolveCurrentProjectDir();
|
||||
if (!projectDir) {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ success: false, error: "ACAI_PROJECT_DIR not set" }) }],
|
||||
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Project dir no disponible en esta sesion" }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Step 1: Try reading fresh token from .acai (Python server may have already refreshed it)
|
||||
let token = "";
|
||||
let tokenHash = "";
|
||||
let domain = "";
|
||||
const acaiUser = resolveCurrentAcaiUser();
|
||||
|
||||
// Delegamos al Python que ya gestiona expiracion + refresh + persistencia
|
||||
let info;
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(acaiFilePath, "utf-8"));
|
||||
token = data.token || "";
|
||||
tokenHash = data.tokenHash || "";
|
||||
domain = data.domain || "";
|
||||
info = await fetchProjectInfo({ project_dir: projectDir }, acaiUser);
|
||||
} catch (e) {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ success: false, error: `Cannot read .acai: ${e.message}` }) }],
|
||||
content: [{ type: "text", text: JSON.stringify({ success: false, error: `Token refresh failed: ${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;
|
||||
if (!info?.success) {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ success: false, error: info?.error || "Project info resolution failed" }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Step 3: If expired, ask Python server to refresh it
|
||||
if (isExpired) {
|
||||
try {
|
||||
const info = await fetchProjectInfo({ project_dir: projectDir });
|
||||
token = info?.token || token;
|
||||
tokenHash = info?.tokenHash || tokenHash;
|
||||
domain = info?.domain || domain;
|
||||
} catch (e) {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ success: false, error: `Token refresh failed: ${e.message}` }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
// Comparamos token previo para saber si hubo renovacion
|
||||
const mcpSessionId = getCurrentSessionId();
|
||||
let previousToken = null;
|
||||
if (mcpSessionId) {
|
||||
// Leer creds previas sin tocar lastAccess via interno no expuesto:
|
||||
// usamos sessionCredentials como espejo si existe, sino null.
|
||||
const prev = sessionCredentials.get(mcpSessionId);
|
||||
previousToken = prev?.token || null;
|
||||
}
|
||||
|
||||
// Step 4: Update credentials in memory from the canonical server resolver
|
||||
const info = await fetchProjectInfo({ project_dir: projectDir });
|
||||
const webUrl = info?.web_url || process.env.ACAI_WEB_URL || "";
|
||||
const apiWebUrl = info?.api_web_url || process.env.ACAI_API_WEB_URL || webUrl;
|
||||
const website = info?.domain || domain || process.env.ACAI_WEBSITE || "";
|
||||
const freshCreds = {
|
||||
token,
|
||||
tokenHash,
|
||||
website,
|
||||
web_url: webUrl,
|
||||
api_web_url: apiWebUrl,
|
||||
forge_host: info?.forge_host || null,
|
||||
profileName: "stdio",
|
||||
token: info.token || "",
|
||||
tokenHash: info.tokenHash || "",
|
||||
website: info.domain || "",
|
||||
web_url: info.web_url || "",
|
||||
api_web_url: info.api_web_url || info.web_url || "",
|
||||
forge_host: info.forge_host || null,
|
||||
project_dir: info.project_dir || projectDir,
|
||||
acai_user: acaiUser || null,
|
||||
profileName: acaiUser || "mcp-session",
|
||||
role: "developer",
|
||||
};
|
||||
sessionCredentials.set("_default", freshCreds);
|
||||
|
||||
// Persistir en la sesion MCP activa (HTTP multi-tenant)
|
||||
if (mcpSessionId) {
|
||||
setMcpSessionCredentials(mcpSessionId, freshCreds);
|
||||
sessionCredentials.set(mcpSessionId, freshCreds);
|
||||
}
|
||||
// Compatibilidad stdio (cuando extra.sessionId viene del SDK)
|
||||
if (extra?.sessionId) {
|
||||
sessionCredentials.set(extra.sessionId, freshCreds);
|
||||
}
|
||||
|
||||
const rotated = previousToken && previousToken !== freshCreds.token;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: "Token refreshed successfully",
|
||||
expired_before: isExpired,
|
||||
domain: website,
|
||||
message: rotated ? "Token refreshed (rotated by Python)" : "Token refreshed successfully",
|
||||
domain: freshCreds.website,
|
||||
rotated: !!rotated,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import axios from "axios";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Resuelve `project_dir` para la tool en curso.
|
||||
*
|
||||
* Orden de precedencia:
|
||||
* 1. AsyncLocalStorage (mcpSessionId) -> credenciales de la sesion HTTP
|
||||
* 2. process.env.ACAI_PROJECT_DIR (modo stdio / fallback legacy)
|
||||
*
|
||||
* Devuelve string vacio si no hay forma de resolverlo — el caller decide
|
||||
* como manejar el error.
|
||||
*/
|
||||
export function resolveCurrentProjectDir() {
|
||||
const sessionId = getCurrentSessionId();
|
||||
if (sessionId) {
|
||||
const creds = getMcpSessionCredentials(sessionId);
|
||||
if (creds?.project_dir) return creds.project_dir;
|
||||
}
|
||||
return process.env.ACAI_PROJECT_DIR || "";
|
||||
}
|
||||
|
||||
export function getCurrentProjectInfo() {
|
||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
||||
const projectDir = resolveCurrentProjectDir();
|
||||
if (!projectDir) {
|
||||
throw new Error("ACAI_PROJECT_DIR not set");
|
||||
}
|
||||
@@ -15,6 +37,11 @@ export function getCurrentProjectInfo() {
|
||||
|
||||
export async function callLocalFileEndpoint(method, endpoint, payload = null, query = null) {
|
||||
const headers = getLocalServerHeaders();
|
||||
// Inyectar X-Acai-User cuando hay sesion HTTP activa: permite que los
|
||||
// endpoints autenticados del server Python identifiquen al usuario sin
|
||||
// depender de Authorization Basic.
|
||||
const acaiUser = resolveCurrentAcaiUser();
|
||||
if (acaiUser) headers["X-Acai-User"] = acaiUser;
|
||||
if (method === "GET") {
|
||||
const response = await axios.get(`${LOCAL_SERVER_URL}${endpoint}`, {
|
||||
params: query || undefined,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { resolveCurrentProjectDir } from '../files/helpers.js';
|
||||
|
||||
/**
|
||||
* Check if the current user has write access to a table.
|
||||
@@ -16,7 +17,7 @@ import path from 'path';
|
||||
* en lugar del .acai para determinar el modo.
|
||||
*/
|
||||
export function canAccessTable(tableName) {
|
||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
||||
const projectDir = resolveCurrentProjectDir();
|
||||
if (!projectDir) return { allowed: true }; // no project dir, don't block
|
||||
|
||||
const acaiFile = path.join(projectDir, ".acai");
|
||||
|
||||
@@ -1,21 +1,45 @@
|
||||
import axios from "axios";
|
||||
import { resolveCurrentAcaiUser } from "./sessionHelpers.js";
|
||||
|
||||
const PYTHON_BASE = `http://app:${process.env.ACAI_PORT || 9091}`;
|
||||
|
||||
export async function pythonPost(path, data, timeout = 120000) {
|
||||
/**
|
||||
* Construye el set de headers comunes para llamadas al server Python interno.
|
||||
* Inyecta automaticamente `X-Acai-User` cuando hay sesion MCP activa con
|
||||
* `acai_user` conocido, lo que permite a los endpoints autenticados identificar
|
||||
* al usuario sin Authorization Basic.
|
||||
*/
|
||||
function buildPythonHeaders(extra = {}) {
|
||||
const authHeader = process.env.ACAI_AUTH_HEADER || "";
|
||||
const mode = process.env.ACAI_MODE_OVERRIDE || process.env.ACAI_MODE || "";
|
||||
const role = process.env.ACAI_ROLE_OVERRIDE || "";
|
||||
const acaiUser = resolveCurrentAcaiUser();
|
||||
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...(authHeader ? { "Authorization": authHeader } : {}),
|
||||
...(mode ? { "X-Acai-Mode": mode } : {}),
|
||||
...(role ? { "X-Acai-Role": role } : {}),
|
||||
...(acaiUser ? { "X-Acai-User": acaiUser } : {}),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
export async function pythonPost(path, data, timeout = 120000) {
|
||||
const response = await axios.post(`${PYTHON_BASE}${path}`, data, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(authHeader ? { "Authorization": authHeader } : {}),
|
||||
...(mode ? { "X-Acai-Mode": mode } : {}),
|
||||
...(role ? { "X-Acai-Role": role } : {}),
|
||||
},
|
||||
headers: buildPythonHeaders(),
|
||||
timeout,
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function pythonGet(path, params = null, timeout = 30000) {
|
||||
const response = await axios.get(`${PYTHON_BASE}${path}`, {
|
||||
params: params || undefined,
|
||||
headers: buildPythonHeaders(),
|
||||
timeout,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
27
mcp-server/tools/helpers/sessionHelpers.js
Normal file
27
mcp-server/tools/helpers/sessionHelpers.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Helpers reutilizables para resolver datos derivados de la sesion MCP en curso.
|
||||
*
|
||||
* Usan AsyncLocalStorage (`utils/sessionContext.js`) para recuperar el
|
||||
* `mcpSessionId` activo y leer informacion asociada desde
|
||||
* `auth/credentials.js`. En modo stdio (sin HTTP) devuelven `null` y el caller
|
||||
* decide como actuar.
|
||||
*/
|
||||
import { getCurrentSessionId } from "../../utils/sessionContext.js";
|
||||
import { getMcpSessionCredentials } from "../../auth/credentials.js";
|
||||
|
||||
/**
|
||||
* Recupera el `acai_user` de la sesion HTTP activa (si existe).
|
||||
*
|
||||
* Se usa para inyectar el header `X-Acai-User` en llamadas al server Python,
|
||||
* evitando asi depender de Authorization Basic y permitiendo que los endpoints
|
||||
* autenticados (p.ej. `/api/generate-image`, `/api/files/write`) identifiquen
|
||||
* al usuario propietario del proyecto.
|
||||
*
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function resolveCurrentAcaiUser() {
|
||||
const sessionId = getCurrentSessionId();
|
||||
if (!sessionId) return null;
|
||||
const creds = getMcpSessionCredentials(sessionId);
|
||||
return creds?.acai_user || null;
|
||||
}
|
||||
@@ -6,12 +6,13 @@ import { withAuth } from "../../auth/index.js";
|
||||
import { handleToolError } from "../helpers/errorHandler.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
import { pythonPost } from "../helpers/pythonServerClient.js";
|
||||
import { resolveCurrentProjectDir } from "../files/helpers.js";
|
||||
|
||||
// --- Verificacion de creditos ---
|
||||
const WS_BASE = "https://ws.cocosolution.com/api/handler_acaicode.php";
|
||||
|
||||
function getAcaiToken() {
|
||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
||||
const projectDir = resolveCurrentProjectDir();
|
||||
if (!projectDir) return null;
|
||||
try {
|
||||
const acaiFile = path.join(projectDir, ".acai");
|
||||
@@ -56,7 +57,7 @@ export function registerGenerateImageTool(server) {
|
||||
};
|
||||
}
|
||||
|
||||
const projectSlug = path.basename(process.env.ACAI_PROJECT_DIR || "");
|
||||
const projectSlug = path.basename(resolveCurrentProjectDir());
|
||||
const safeFileName = fileName || `generated-${Date.now()}`;
|
||||
const destRelativePath = `cms/uploads/generated/${safeFileName}.jpg`;
|
||||
const fullPrompt = style ? `${style} style: ${prompt}` : prompt;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { handleToolError, validateRequired, handleApiResponse } from "../helpers
|
||||
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
|
||||
@@ -78,7 +79,7 @@ async function resolveLocalImageAsBase64(imageUrl) {
|
||||
}
|
||||
|
||||
// Intento B: resolver el pathname contra ACAI_PROJECT_DIR y leer del disco
|
||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
||||
const projectDir = resolveCurrentProjectDir();
|
||||
if (projectDir && parsed.pathname) {
|
||||
try {
|
||||
const localPath = path.join(projectDir, parsed.pathname);
|
||||
@@ -118,7 +119,7 @@ export function registerUploadRecordImageTool(server) {
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const projectSlug = path.basename(process.env.ACAI_PROJECT_DIR || "");
|
||||
const projectSlug = path.basename(resolveCurrentProjectDir());
|
||||
|
||||
// Intentar via Python server (tiene sync + optimizacion)
|
||||
let result;
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
import { resolveCurrentAcaiUser } from "../helpers/sessionHelpers.js";
|
||||
|
||||
export function registerCompileModuleTool(server) {
|
||||
server.tool(
|
||||
@@ -44,11 +45,16 @@ Pass the full path to the index-base.tpl file and the project directory.`,
|
||||
? { project: projectSlug, relativePath, project_dir: projectDir }
|
||||
: { file: filePath, project_dir: projectDir };
|
||||
|
||||
// Call the Python server compile endpoint
|
||||
// Call the Python server compile endpoint. Inyectar X-Acai-User
|
||||
// cuando hay sesion HTTP activa para que el endpoint autenticado
|
||||
// resuelva el proyecto dentro de /opt/acai/webs/<user>/.
|
||||
const acaiUser = resolveCurrentAcaiUser();
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (acaiUser) headers["X-Acai-User"] = acaiUser;
|
||||
const response = await axios.post(
|
||||
`${LOCAL_SERVER_URL}/api/compile-module`,
|
||||
payload,
|
||||
{ headers: { "Content-Type": "application/json" }, timeout: 30000 }
|
||||
{ headers, timeout: 30000 }
|
||||
);
|
||||
|
||||
if (response.data?.ok) {
|
||||
|
||||
@@ -4,6 +4,8 @@ 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";
|
||||
import { resolveCurrentProjectDir } from "../files/helpers.js";
|
||||
import { resolveCurrentAcaiUser } from "../helpers/sessionHelpers.js";
|
||||
|
||||
export function registerCreateModuleTool(server) {
|
||||
server.tool(
|
||||
@@ -35,7 +37,7 @@ Parameters:
|
||||
const validationError = validateRequired({ moduleId, html }, ['moduleId', 'html'], 'create_module');
|
||||
if (validationError) return validationError;
|
||||
|
||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
||||
const projectDir = resolveCurrentProjectDir();
|
||||
if (!projectDir) {
|
||||
return { content: [{ type: "text", text: "Error: ACAI_PROJECT_DIR not set" }], isError: true };
|
||||
}
|
||||
@@ -43,10 +45,15 @@ Parameters:
|
||||
moduleId = moduleId.toLowerCase().replace(/\s+/g, '_'); // Ensure moduleId is lowercase and uses underscores
|
||||
moduleId = moduleId + "_" + (Math.random().toString(36).substring(2, 8).toUpperCase());
|
||||
|
||||
// Inyectar X-Acai-User para que el endpoint autenticado del
|
||||
// server Python resuelva rutas dentro de /opt/acai/webs/<user>/.
|
||||
const acaiUser = resolveCurrentAcaiUser();
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (acaiUser) headers["X-Acai-User"] = acaiUser;
|
||||
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 }
|
||||
{ headers, timeout: 30000 }
|
||||
);
|
||||
|
||||
if (response.data?.success) {
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { resolveCurrentAcaiUser } from "../helpers/sessionHelpers.js";
|
||||
import axios from "axios";
|
||||
|
||||
export function registerNavigateBrowserTool(server) {
|
||||
@@ -30,12 +31,16 @@ export function registerNavigateBrowserTool(server) {
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
const project = credentials.website || process.env.ACAI_WEBSITE || "";
|
||||
|
||||
// POST to Python server to set pending navigation
|
||||
// POST to Python server to set pending navigation.
|
||||
// Inyectar X-Acai-User cuando la sesion MCP lo provee.
|
||||
const acaiUser = resolveCurrentAcaiUser();
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (acaiUser) headers["X-Acai-User"] = acaiUser;
|
||||
await axios.post(`${LOCAL_SERVER_URL}/api/browser/navigate`, {
|
||||
project: project,
|
||||
enlace: enlace,
|
||||
}, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { handleToolError } from "../helpers/errorHandler.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { resolveCurrentProjectDir } from "../files/helpers.js";
|
||||
|
||||
export function registerSaveProjectStylesTool(server) {
|
||||
server.tool(
|
||||
@@ -24,8 +25,8 @@ The content should include: color palette (hex values), typography, spacing patt
|
||||
};
|
||||
}
|
||||
|
||||
// Get project directory from env
|
||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
||||
// Get project directory from session (HTTP) or env (stdio fallback)
|
||||
const projectDir = resolveCurrentProjectDir();
|
||||
if (!projectDir) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: ACAI_PROJECT_DIR not set" }],
|
||||
|
||||
Reference in New Issue
Block a user