diff --git a/mcp-server/auth/credentials.js b/mcp-server/auth/credentials.js index c72b5e7..e865c0e 100644 --- a/mcp-server/auth/credentials.js +++ b/mcp-server/auth/credentials.js @@ -11,7 +11,12 @@ * (the SimpleAuth header that Claude sends with each request). */ +import fs from "node:fs"; +import path from "node:path"; +import { fetchProjectInfo } from "./localClient.js"; + const DEFAULT_ROLE = 'developer'; +const FORGE_INTERNAL_URL = process.env.ACAI_FORGE_WEB_URL || "http://web:80"; // Session-based credentials storage (ephemeral, per-session) @@ -46,6 +51,102 @@ const cleanupExpiredMcpSessions = () => { // Run cleanup every 5 minutes setInterval(cleanupExpiredMcpSessions, 5 * 60 * 1000); +const buildApiUrlFromPublicUrl = (webUrl, explicitForgeHost = "") => { + if (!webUrl) return null; + if (explicitForgeHost) return FORGE_INTERNAL_URL; + try { + const parsed = new URL(webUrl); + if (parsed.hostname.includes(".forge.")) { + return FORGE_INTERNAL_URL; + } + } catch { + return null; + } + return webUrl; +}; + +const readProjectAcaiFallback = () => { + const projectDir = process.env.ACAI_PROJECT_DIR || ""; + if (!projectDir) return null; + + const acaiFilePath = path.join(projectDir, ".acai"); + if (!fs.existsSync(acaiFilePath)) return null; + + try { + const data = JSON.parse(fs.readFileSync(acaiFilePath, "utf-8")); + const website = data.domain || process.env.ACAI_WEBSITE || null; + let webUrl = data.local_web_url || process.env.ACAI_WEB_URL || (website ? `https://${website}` : null); + let forgeHost = data.local_forge_host || process.env.ACAI_FORGE_HOST || null; + // Respeta local_api_web_url del .acai si esta presente (override del usuario); + // si no, fallback al calculo automatico (web:80 para forge, web_url para Docker). + let apiWebUrl = data.local_api_web_url || process.env.ACAI_API_WEB_URL || buildApiUrlFromPublicUrl(webUrl, forgeHost); + let mode = data.mode || process.env.ACAI_MODE || "local"; + + // Override de entorno (inyectado por cronjobs via mcp_env) + if (process.env.ACAI_MODE_OVERRIDE) { + mode = process.env.ACAI_MODE_OVERRIDE; + if (process.env.ACAI_WEB_URL_OVERRIDE) webUrl = process.env.ACAI_WEB_URL_OVERRIDE; + if (process.env.ACAI_API_WEB_URL_OVERRIDE) apiWebUrl = process.env.ACAI_API_WEB_URL_OVERRIDE; + if (process.env.ACAI_FORGE_HOST_OVERRIDE !== undefined) forgeHost = process.env.ACAI_FORGE_HOST_OVERRIDE; + } + + return { + token: data.token || process.env.ACAI_TOKEN || null, + website, + web_url: webUrl, + api_web_url: apiWebUrl, + forge_host: forgeHost, + tokenHash: data.tokenHash || process.env.ACAI_TOKEN_HASH || null, + mode, + profileName: "acai-file", + role: DEFAULT_ROLE, + }; + } catch (error) { + console.error(`[Credentials] Failed to read .acai fallback: ${error.message}`); + return null; + } +}; + +const resolveLocalProjectFallback = async () => { + const projectDir = process.env.ACAI_PROJECT_DIR || ""; + + if (projectDir) { + try { + const info = await fetchProjectInfo({ project_dir: projectDir }); + if (info?.success) { + let webUrl = info.web_url || process.env.ACAI_WEB_URL || null; + let apiWebUrl = info.api_web_url || buildApiUrlFromPublicUrl(info.web_url, info.forge_host) || null; + let forgeHost = info.forge_host || process.env.ACAI_FORGE_HOST || null; + let mode = info.mode || process.env.ACAI_MODE || "local"; + + // Override de entorno (inyectado por cronjobs via mcp_env) + if (process.env.ACAI_MODE_OVERRIDE) { + mode = process.env.ACAI_MODE_OVERRIDE; + if (process.env.ACAI_WEB_URL_OVERRIDE) webUrl = process.env.ACAI_WEB_URL_OVERRIDE; + if (process.env.ACAI_API_WEB_URL_OVERRIDE) apiWebUrl = process.env.ACAI_API_WEB_URL_OVERRIDE; + if (process.env.ACAI_FORGE_HOST_OVERRIDE !== undefined) forgeHost = process.env.ACAI_FORGE_HOST_OVERRIDE; + } + + return { + token: info.token || process.env.ACAI_TOKEN || null, + website: info.domain || process.env.ACAI_WEBSITE || null, + web_url: webUrl, + api_web_url: apiWebUrl, + forge_host: forgeHost, + tokenHash: info.tokenHash || process.env.ACAI_TOKEN_HASH || null, + mode, + profileName: "project-info", + role: DEFAULT_ROLE, + }; + } + } catch (error) { + console.error(`[Credentials] project-info fallback failed: ${error.message}`); + } + } + + return readProjectAcaiFallback(); +}; + /** * Get credentials by MCP-Session-Id */ @@ -132,10 +233,38 @@ export const getSessionCredentials = async (sessionId, inlineCredentials = null) // Priority 2: Session credentials const sessionCreds = sessionCredentials.get(sessionId); if (sessionCreds) { + if (sessionCreds.token && sessionCreds.web_url && sessionCreds.api_web_url) { + console.error(`[Credentials] getSessionCredentials(${sessionId}) - FOUND: website=${sessionCreds.website}, hasToken=${!!sessionCreds.token}`); + return sessionCreds; + } + const hydrated = await resolveLocalProjectFallback(); + if (hydrated) { + const merged = { + ...sessionCreds, + token: sessionCreds.token || hydrated.token, + website: sessionCreds.website || hydrated.website, + web_url: sessionCreds.web_url || hydrated.web_url, + api_web_url: sessionCreds.api_web_url || hydrated.api_web_url, + forge_host: sessionCreds.forge_host || hydrated.forge_host, + tokenHash: sessionCreds.tokenHash || hydrated.tokenHash, + profileName: sessionCreds.profileName || hydrated.profileName, + role: sessionCreds.role || hydrated.role, + }; + sessionCredentials.set(sessionId, merged); + console.error(`[Credentials] getSessionCredentials(${sessionId}) - HYDRATED from local fallback`); + return merged; + } console.error(`[Credentials] getSessionCredentials(${sessionId}) - FOUND: website=${sessionCreds.website}, hasToken=${!!sessionCreds.token}`); return sessionCreds; } + const localFallback = await resolveLocalProjectFallback(); + if (localFallback) { + sessionCredentials.set(sessionId, localFallback); + console.error(`[Credentials] getSessionCredentials(${sessionId}) - USING local project fallback`); + return localFallback; + } + // Priority 3: Fallback to environment variables (for backwards compatibility) console.error(`[Credentials] getSessionCredentials(${sessionId}) - NOT FOUND, using env fallback`); console.error(`[Credentials] Active sessions: [${Array.from(sessionCredentials.keys()).join(', ')}]`); diff --git a/mcp-server/auth/localClient.js b/mcp-server/auth/localClient.js index 7d41102..5e755dd 100644 --- a/mcp-server/auth/localClient.js +++ b/mcp-server/auth/localClient.js @@ -1,14 +1,18 @@ import axios from "axios"; -import { LOCAL_SERVER_URL } from "../config/index.js"; +import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../config/index.js"; export async function fetchProjectInfo(projectName) { - const response = await axios.get(`${LOCAL_SERVER_URL}/api/mcp/project-info`, { - params: { project: projectName } + const params = typeof projectName === "string" ? { project: projectName } : (projectName || {}); + const response = await axios.get(`${LOCAL_SERVER_URL}/api/project-info`, { + params, + headers: getLocalServerHeaders(), }); return response.data; // { success, web_url, token, tokenHash, domain, project_dir } } export async function fetchProjectsList() { - const response = await axios.get(`${LOCAL_SERVER_URL}/api/mcp/projects`); + const response = await axios.get(`${LOCAL_SERVER_URL}/api/mcp/projects`, { + headers: getLocalServerHeaders(), + }); return response.data; // { success, projects: [...] } } diff --git a/mcp-server/httpServer.js b/mcp-server/httpServer.js index fb51a30..e083c8a 100644 --- a/mcp-server/httpServer.js +++ b/mcp-server/httpServer.js @@ -85,6 +85,7 @@ const resolveProjectCredentials = async (projectName) => { web_url: info.web_url, api_web_url: info.api_web_url || info.web_url, forge_host: info.forge_host || null, + mode: info.mode || "local", }; } catch (error) { throw new Error(`Failed to resolve project '${projectName}': ${error.message}`); @@ -106,6 +107,7 @@ const configureSessionCredentials = async (sessionId, { token, tokenHash, websit web_url: projectCreds.web_url, api_web_url: projectCreds.api_web_url || projectCreds.web_url, forge_host: projectCreds.forge_host || null, + mode: projectCreds.mode || "local", profileName: 'project-' + projectName, role: 'developer', }); diff --git a/mcp-server/stdio.js b/mcp-server/stdio.js index 002b40e..a908c06 100644 --- a/mcp-server/stdio.js +++ b/mcp-server/stdio.js @@ -2,17 +2,16 @@ * Acai Code MCP Server - Stdio Entry Point * * Used when Claude Code launches the MCP server directly via .mcp.json. - * Reads credentials from .acai file on each tool call (auto-refresh on token renewal). + * Reads credentials from the local Python server on each tool call. */ -import fs from "fs"; -import path from "path"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { createMcpServer } from "./server.js"; import { registerPrompts } from "./prompts/index.js"; import { registerTools } from "./tools/index.js"; import { registerResources } from "./resources/index.js"; import { sessionCredentials } from "./auth/credentials.js"; +import { fetchProjectInfo } from "./auth/localClient.js"; // Create server instance const server = createMcpServer(); @@ -20,75 +19,71 @@ registerPrompts(server); registerTools(server); registerResources(server); -// Static env vars (web_url and website don't change, token does) const projectDir = process.env.ACAI_PROJECT_DIR || ""; -const acaiFilePath = projectDir ? path.join(projectDir, ".acai") : ""; -// Read .acai once at startup for URL fallbacks -let acaiFileData = {}; -if (acaiFilePath) { - try { - acaiFileData = JSON.parse(fs.readFileSync(acaiFilePath, "utf-8")); - } catch { /* ignore - fall back to env vars */ } +// Aplica vars de override de entorno (usado por cronjobs para forzar el +// entorno objetivo sin tocar el .acai del proyecto). Modifica creds in-place. +function applyEnvironmentOverride(creds) { + const modeOverride = process.env.ACAI_MODE_OVERRIDE; + if (!modeOverride) return creds; + creds.mode = modeOverride; + if (process.env.ACAI_WEB_URL_OVERRIDE) creds.web_url = process.env.ACAI_WEB_URL_OVERRIDE; + if (process.env.ACAI_API_WEB_URL_OVERRIDE) creds.api_web_url = process.env.ACAI_API_WEB_URL_OVERRIDE; + if (process.env.ACAI_FORGE_HOST_OVERRIDE !== undefined) { + creds.forge_host = process.env.ACAI_FORGE_HOST_OVERRIDE; + } + return creds; } -const website = process.env.ACAI_WEBSITE || acaiFileData.domain || ""; -const webUrl = process.env.ACAI_WEB_URL || acaiFileData.local_web_url || ""; -const derivedForgeHost = (() => { - // First check .acai for explicit forge host - if (acaiFileData.local_forge_host) return acaiFileData.local_forge_host; - if (!webUrl) return ""; - try { - const parsed = new URL(webUrl); - return parsed.hostname.includes("forge.acaisuite.com") ? parsed.host : ""; - } catch { - return ""; - } -})(); -const apiWebUrl = process.env.ACAI_API_WEB_URL || (derivedForgeHost ? "http://web:80/" : webUrl); -const forgeHost = process.env.ACAI_FORGE_HOST || derivedForgeHost; - -// Read fresh credentials from .acai file -function readFreshCredentials() { - let token = process.env.ACAI_TOKEN || ""; - let tokenHash = process.env.ACAI_TOKEN_HASH || ""; - - // If .acai file exists, read fresh token from disk (renewed by Python server) - if (acaiFilePath) { +async function readFreshCredentials() { + if (projectDir) { try { - const data = JSON.parse(fs.readFileSync(acaiFilePath, "utf-8")); - if (data.token) token = data.token; - if (data.tokenHash) tokenHash = data.tokenHash; - } catch { - // Fall back to env vars if .acai can't be read + const data = await fetchProjectInfo({ project_dir: projectDir }); + if (data?.success) { + return applyEnvironmentOverride({ + token: data.token || "", + tokenHash: data.tokenHash || "", + website: data.domain || "", + web_url: data.web_url || "", + api_web_url: data.api_web_url || data.web_url || "", + forge_host: data.forge_host || "", + mode: data.mode || "local", + profileName: "stdio", + role: "developer", + }); + } + } catch (error) { + console.error(`[MCP stdio] Failed to resolve project-info: ${error.message}`); } } - return { - token, - tokenHash, - website, - web_url: webUrl, - api_web_url: apiWebUrl, - forge_host: forgeHost, - profileName: "stdio", + return applyEnvironmentOverride({ + token: process.env.ACAI_TOKEN || "", + tokenHash: process.env.ACAI_TOKEN_HASH || "", + website: process.env.ACAI_WEBSITE || "", + web_url: process.env.ACAI_WEB_URL || "", + api_web_url: process.env.ACAI_API_WEB_URL || "", + forge_host: process.env.ACAI_FORGE_HOST || "", + mode: process.env.ACAI_MODE || "local", + profileName: "stdio-fallback", role: "developer", - }; + }); } -if (!webUrl) { +const initialCreds = await readFreshCredentials(); + +if (!initialCreds.web_url) { console.error("[MCP stdio] WARNING: No ACAI_WEB_URL in environment. Tools will fail."); } // Set initial credentials -sessionCredentials.set("_default", readFreshCredentials()); +sessionCredentials.set("_default", initialCreds); -// Intercept tool calls to refresh credentials from .acai before each call +// Intercept tool calls to refresh credentials from the Python server before each call const _origSetHandler = server.server.setRequestHandler; server.server.setRequestHandler = (schema, handler) => { return _origSetHandler.call(server.server, schema, async (request, extra) => { - // Re-read .acai on every tool call to pick up renewed tokens - const freshCreds = readFreshCredentials(); + const freshCreds = await readFreshCredentials(); sessionCredentials.set("_default", freshCreds); if (extra?.sessionId) { sessionCredentials.set(extra.sessionId, freshCreds); @@ -100,4 +95,4 @@ server.server.setRequestHandler = (schema, handler) => { // Connect via stdio transport const transport = new StdioServerTransport(); await server.connect(transport); -console.error(`[MCP stdio] Connected — ${website} → ${webUrl} (project: ${projectDir})`); +console.error(`[MCP stdio] Connected — ${initialCreds.website} → ${initialCreds.web_url} (project: ${projectDir})`); diff --git a/mcp-server/tools/auth/index.js b/mcp-server/tools/auth/index.js index bbd1c86..a810da1 100644 --- a/mcp-server/tools/auth/index.js +++ b/mcp-server/tools/auth/index.js @@ -1,11 +1,9 @@ import { z } from "zod"; import fs from "fs"; import path from "path"; -import axios from "axios"; import { sessionCredentials } from "../../auth/credentials.js"; import { withAuthParams } from "../helpers/authSchema.js"; - -const LOCAL_SERVER_URL = `http://localhost:${process.env.ACAI_HOST_PORT || 29871}`; +import { fetchProjectInfo } from "../../auth/localClient.js"; export function registerAuthTools(server) { server.tool( @@ -54,14 +52,10 @@ export function registerAuthTools(server) { // Step 3: If expired, ask Python server to refresh it if (isExpired) { try { - // Call the compile-module endpoint pattern — but we need a refresh endpoint - // Use the server's existing auto-refresh: just call any endpoint that triggers refresh - // The simplest: GET /api/projects which auto-refreshes expired tokens - const res = await axios.get(`${LOCAL_SERVER_URL}/api/projects`, { timeout: 15000 }); - // Re-read .acai after server refreshed it - const data = JSON.parse(fs.readFileSync(acaiFilePath, "utf-8")); - token = data.token || ""; - tokenHash = data.tokenHash || ""; + 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}` }) }], @@ -70,14 +64,18 @@ export function registerAuthTools(server) { } } - // Step 4: Update credentials in memory - const webUrl = process.env.ACAI_WEB_URL || ""; - const website = domain || process.env.ACAI_WEBSITE || ""; + // 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", role: "developer", }; diff --git a/mcp-server/tools/helpers/accessControl.js b/mcp-server/tools/helpers/accessControl.js index 8d3fc0d..1260ebb 100644 --- a/mcp-server/tools/helpers/accessControl.js +++ b/mcp-server/tools/helpers/accessControl.js @@ -5,6 +5,15 @@ import path from 'path'; * Check if the current user has write access to a table. * Reads .acai file from ACAI_PROJECT_DIR. * Returns { allowed: true } or { allowed: false, error: "..." } + * + * NOTA: Esta funcion NO depende del campo `mode`. En modo produccion los + * registros de tablas (contenido CMS) se siguen pudiendo editar — son los + * datos reales del usuario, no codigo. El bloqueo de produccion solo aplica + * a escritura de archivos de codigo (gestionado por is_project_admin en el + * server Python al recibir POST /api/files/*). + * + * Si existe ACAI_MODE_OVERRIDE en el entorno (cronjob con override), se usa + * en lugar del .acai para determinar el modo. */ export function canAccessTable(tableName) { const projectDir = process.env.ACAI_PROJECT_DIR || ""; @@ -14,6 +23,10 @@ export function canAccessTable(tableName) { try { if (!fs.existsSync(acaiFile)) return { allowed: true }; const data = JSON.parse(fs.readFileSync(acaiFile, "utf-8")); + // Override de modo (cronjobs lo inyectan via env var) + if (process.env.ACAI_MODE_OVERRIDE) { + data.mode = process.env.ACAI_MODE_OVERRIDE; + } const user = data.user || {}; // Admin has full access diff --git a/mcp-server/tools/media/uploadImageToAssets.js b/mcp-server/tools/media/uploadImageToAssets.js index a0326d4..3c597db 100644 --- a/mcp-server/tools/media/uploadImageToAssets.js +++ b/mcp-server/tools/media/uploadImageToAssets.js @@ -154,7 +154,7 @@ export function registerUploadImageToAssetsTool(server) { // Upload using saveFileBuilder const uploadResult = await saveFileBuilder({ - web_url: credentials.web_url, + web_url: credentials.api_web_url || credentials.web_url, token: credentials.token, tokenHash: credentials.tokenHash, path: assetsPath, diff --git a/mcp-server/tools/project/getWebUrl.js b/mcp-server/tools/project/getWebUrl.js index babf7c1..23447fd 100644 --- a/mcp-server/tools/project/getWebUrl.js +++ b/mcp-server/tools/project/getWebUrl.js @@ -1,5 +1,5 @@ import { z } from "zod"; -import { withAuth, getSessionCredentials } from "../../auth/index.js"; +import { getSessionCredentials } from "../../auth/index.js"; import { handleToolError } from "../helpers/errorHandler.js"; import { withAuthParams } from "../helpers/authSchema.js"; @@ -9,9 +9,10 @@ export function registerGetWebUrlTool(server) { `Get the correct URL for the project's development website. Always use this URL for fetch, Playwright, or any HTTP request to the site. Never guess or use production domains.`, withAuthParams({}), { readOnlyHint: true, destructiveHint: false }, - withAuth(async (_params, extra) => { + async (_params, extra) => { try { - const credentials = await getSessionCredentials(extra.sessionId); + const sessionId = extra?.sessionId || "_default"; + const credentials = await getSessionCredentials(sessionId); if (!credentials || !credentials.web_url) { return { @@ -20,17 +21,11 @@ export function registerGetWebUrlTool(server) { }; } - // Inside Docker, HTTPS is not available — force HTTP for internal requests - let webUrl = credentials.web_url; - if (webUrl && webUrl.startsWith("https://") && webUrl.includes(".forge.")) { - webUrl = webUrl.replace("https://", "http://"); - } - return { content: [{ type: "text", text: JSON.stringify({ - web_url: webUrl, + web_url: credentials.web_url, api_web_url: credentials.api_web_url || null, website: credentials.website || null, note: "Always use web_url for Playwright/fetch. IMPORTANT: Always append ?pruebas=1 to any URL you visit (e.g. web_url + '/?pruebas=1' or web_url + '/servicios/?pruebas=1'). Never use the production domain directly.", @@ -40,6 +35,6 @@ export function registerGetWebUrlTool(server) { } catch (error) { return handleToolError(error, "get_web_url", {}); } - }) + } ); } diff --git a/mcp-server/utils/cmsTargetSafety.js b/mcp-server/utils/cmsTargetSafety.js index e65cdb1..2bd8ef4 100644 --- a/mcp-server/utils/cmsTargetSafety.js +++ b/mcp-server/utils/cmsTargetSafety.js @@ -11,6 +11,7 @@ function parseUrl(url, fieldName, context) { export function assertSafeCmsTarget(target, context = "cms") { const publicUrl = typeof target === "string" ? target : (target?.web_url || ""); const apiUrl = typeof target === "string" ? target : (target?.api_web_url || ""); + const mode = typeof target === "string" ? "local" : (target?.mode || "local"); if (!apiUrl) { throw new Error( @@ -19,12 +20,6 @@ export function assertSafeCmsTarget(target, context = "cms") { } const parsedApiUrl = parseUrl(apiUrl, "ACAI_API_WEB_URL", context); - if (!SAFE_INTERNAL_HOSTS.has(parsedApiUrl.hostname)) { - throw new Error( - `[${context}] Unsafe ACAI_API_WEB_URL host "${parsedApiUrl.hostname}". ` + - `Only approved local hosts are allowed: ${Array.from(SAFE_INTERNAL_HOSTS).join(", ")}.` - ); - } if (!["http:", "https:"].includes(parsedApiUrl.protocol)) { throw new Error( @@ -32,6 +27,25 @@ export function assertSafeCmsTarget(target, context = "cms") { ); } + // Modo "production": el .acai del proyecto autoriza explicitamente apuntar + // al sitio real. Saltamos el whitelist de hosts internos. Usar SOLO para + // testing/debug controlado — el agente IA puede modificar produccion. + if (mode === "production") { + return { + publicUrl, + apiUrl, + forgeHost: typeof target === "string" ? null : (target?.forge_host || null), + }; + } + + if (!SAFE_INTERNAL_HOSTS.has(parsedApiUrl.hostname)) { + throw new Error( + `[${context}] Unsafe ACAI_API_WEB_URL host "${parsedApiUrl.hostname}". ` + + `Only approved local hosts are allowed: ${Array.from(SAFE_INTERNAL_HOSTS).join(", ")}. ` + + `Set "mode": "production" in .acai to bypass this check (intended for testing only).` + ); + } + if (publicUrl) { const parsedPublicUrl = parseUrl(publicUrl, "ACAI_WEB_URL", context); const publicIsSafeInternal = SAFE_INTERNAL_HOSTS.has(parsedPublicUrl.hostname);