cambios mcp remoto
This commit is contained in:
@@ -105,6 +105,7 @@ const readProjectAcaiFallback = () => {
|
||||
forge_host: forgeHost,
|
||||
tokenHash: data.tokenHash || process.env.ACAI_TOKEN_HASH || null,
|
||||
mode,
|
||||
project_dir: projectDir || null,
|
||||
profileName: "acai-file",
|
||||
role: resolveEffectiveRole(data.role),
|
||||
};
|
||||
@@ -142,6 +143,7 @@ const resolveLocalProjectFallback = async () => {
|
||||
forge_host: forgeHost,
|
||||
tokenHash: info.tokenHash || process.env.ACAI_TOKEN_HASH || null,
|
||||
mode,
|
||||
project_dir: info.project_dir || projectDir || null,
|
||||
profileName: "project-info",
|
||||
role: resolveEffectiveRole(info.role),
|
||||
};
|
||||
@@ -313,8 +315,8 @@ export const setSessionUserToken = (userToken, sessionId) => {
|
||||
* @param {string} sessionId - The session ID (SSE transport session)
|
||||
* @param {string} mcpSessionId - Optional MCP-Session-Id for persistence across SSE reconnections
|
||||
*/
|
||||
export const setCredentials = async ({ website, web_url, api_web_url, forge_host, token, tokenHash, profileName, role }, sessionId, mcpSessionId = null) => {
|
||||
console.error(`[Credentials] setCredentials(${sessionId}) - website=${website}, web_url=${web_url}, api_web_url=${api_web_url}, forge_host=${forge_host || ""}, hasToken=${!!token}, hasTokenHash=${!!tokenHash}, profile=${profileName || "manual"}, role=${role || 'default'}, hasMcpSessionId=${!!mcpSessionId}`);
|
||||
export const setCredentials = async ({ website, web_url, api_web_url, forge_host, token, tokenHash, profileName, role, project_dir }, sessionId, mcpSessionId = null) => {
|
||||
console.error(`[Credentials] setCredentials(${sessionId}) - website=${website}, web_url=${web_url}, api_web_url=${api_web_url}, forge_host=${forge_host || ""}, hasToken=${!!token}, hasTokenHash=${!!tokenHash}, profile=${profileName || "manual"}, role=${role || 'default'}, project_dir=${project_dir || ""}, hasMcpSessionId=${!!mcpSessionId}`);
|
||||
|
||||
const creds = {
|
||||
website,
|
||||
@@ -323,6 +325,7 @@ export const setCredentials = async ({ website, web_url, api_web_url, forge_host
|
||||
forge_host: forge_host || null,
|
||||
token,
|
||||
tokenHash,
|
||||
project_dir: project_dir || null,
|
||||
profileName: profileName || "manual",
|
||||
role: role || DEFAULT_ROLE,
|
||||
};
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import axios from "axios";
|
||||
import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../config/index.js";
|
||||
|
||||
export async function fetchProjectInfo(projectName) {
|
||||
/**
|
||||
* Resuelve info de un proyecto contra el server Python local.
|
||||
*
|
||||
* @param {string|Object} projectName - nombre del proyecto o query object (ej. { project_dir })
|
||||
* @param {string|null} acaiUser - usuario Acai propietario del proyecto. Si se pasa,
|
||||
* se reenvia como header `X-Acai-User` para aislar la busqueda a
|
||||
* `/opt/acai/webs/<user>/`. Nginx valida el secret y añade este header
|
||||
* automaticamente; en modo stdio no se propaga y la logica original se
|
||||
* mantiene.
|
||||
*/
|
||||
export async function fetchProjectInfo(projectName, acaiUser = null) {
|
||||
const params = typeof projectName === "string" ? { project: projectName } : (projectName || {});
|
||||
const headers = getLocalServerHeaders();
|
||||
if (acaiUser) headers["X-Acai-User"] = acaiUser;
|
||||
const response = await axios.get(`${LOCAL_SERVER_URL}/api/project-info`, {
|
||||
params,
|
||||
headers: getLocalServerHeaders(),
|
||||
headers,
|
||||
});
|
||||
return response.data; // { success, web_url, token, tokenHash, domain, project_dir }
|
||||
}
|
||||
|
||||
export async function fetchProjectsList() {
|
||||
export async function fetchProjectsList(acaiUser = null) {
|
||||
const headers = getLocalServerHeaders();
|
||||
if (acaiUser) headers["X-Acai-User"] = acaiUser;
|
||||
const response = await axios.get(`${LOCAL_SERVER_URL}/api/mcp/projects`, {
|
||||
headers: getLocalServerHeaders(),
|
||||
headers,
|
||||
});
|
||||
return response.data; // { success, projects: [...] }
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "./auth/index.js";
|
||||
import { fetchProjectInfo } from "./auth/localClient.js";
|
||||
import { createSessionServer } from "./server.js";
|
||||
import { runWithSession } from "./utils/sessionContext.js";
|
||||
|
||||
// Active sessions - stores { transport, server, type, heartbeatInterval }
|
||||
const activeSessions = new Map();
|
||||
@@ -72,9 +73,9 @@ const verifyJwt = (token) => {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveProjectCredentials = async (projectName) => {
|
||||
const resolveProjectCredentials = async (projectName, acaiUser = null) => {
|
||||
try {
|
||||
const info = await fetchProjectInfo(projectName);
|
||||
const info = await fetchProjectInfo(projectName, acaiUser);
|
||||
if (!info.success) {
|
||||
throw new Error(info.error || "Failed to resolve project info");
|
||||
}
|
||||
@@ -86,20 +87,29 @@ const resolveProjectCredentials = async (projectName) => {
|
||||
api_web_url: info.api_web_url || info.web_url,
|
||||
forge_host: info.forge_host || null,
|
||||
mode: info.mode || "local",
|
||||
project_dir: info.project_dir || null,
|
||||
acai_user: acaiUser || info.acai_user || null,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to resolve project '${projectName}': ${error.message}`);
|
||||
// Si el Python devuelve 404, propagamos el estado original para que el
|
||||
// cliente MCP reciba el fallo real (no lo enmascaramos como 500).
|
||||
if (error.response?.status === 404) {
|
||||
const details = error.response.data?.error || error.response.data?.message || "project not found";
|
||||
const err = new Error(`Project '${typeof projectName === "string" ? projectName : (projectName?.project || projectName?.project_dir || "?")}' not found: ${details}`);
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
throw new Error(`Failed to resolve project '${typeof projectName === "string" ? projectName : (projectName?.project || projectName?.project_dir || "?")}': ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Configure credentials from request headers/query params for a session
|
||||
*/
|
||||
const configureSessionCredentials = async (sessionId, { token, tokenHash, website, web_url, userToken, projectName }) => {
|
||||
const configureSessionCredentials = async (sessionId, { token, tokenHash, website, web_url, userToken, projectName, acaiUser }) => {
|
||||
// Priority 1: Resolve via project name from local Python server
|
||||
if (projectName) {
|
||||
try {
|
||||
const projectCreds = await resolveProjectCredentials(projectName);
|
||||
const projectCreds = await resolveProjectCredentials(projectName, acaiUser);
|
||||
sessionCredentials.set(sessionId, {
|
||||
token: projectCreds.token,
|
||||
tokenHash: projectCreds.tokenHash || null,
|
||||
@@ -108,15 +118,13 @@ const configureSessionCredentials = async (sessionId, { token, tokenHash, websit
|
||||
api_web_url: projectCreds.api_web_url || projectCreds.web_url,
|
||||
forge_host: projectCreds.forge_host || null,
|
||||
mode: projectCreds.mode || "local",
|
||||
project_dir: projectCreds.project_dir || null,
|
||||
acai_user: acaiUser || projectCreds.acai_user || null,
|
||||
profileName: 'project-' + projectName,
|
||||
role: 'developer',
|
||||
});
|
||||
console.log(`[MCP] Session ${sessionId} authenticated via project '${projectName}' - web_url: ${projectCreds.web_url}`);
|
||||
console.log(`[MCP] Session ${sessionId} authenticated via project '${projectName}' (user=${acaiUser || "-"}) - web_url: ${projectCreds.web_url}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[MCP] Failed to resolve project '${projectName}': ${error.message}`);
|
||||
// Fall through to try other auth methods
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Direct credentials (legacy header-based)
|
||||
@@ -151,7 +159,10 @@ const extractCredentialsFromRequest = (req) => {
|
||||
token: url.searchParams.get('token') || req.headers['x-acai-token'],
|
||||
tokenHash: url.searchParams.get('tokenHash') || req.headers['x-acai-token-hash'],
|
||||
website: url.searchParams.get('website') || req.headers['x-acai-website'],
|
||||
userToken: url.searchParams.get('userToken') || req.headers['x-user-token']
|
||||
userToken: url.searchParams.get('userToken') || req.headers['x-user-token'],
|
||||
// Header inyectado por nginx tras validar el secret contra su mapa.
|
||||
// Se usa para aislar la resolucion del proyecto a /opt/acai/webs/<user>/.
|
||||
acaiUser: req.headers['x-acai-user'] || null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -169,7 +180,7 @@ export function startHttpServer() {
|
||||
app.use(cors({
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'X-Acai-Token', 'X-Acai-Website', 'X-Acai-Token-Hash', 'X-User-Token', 'X-Project-Name', 'Authorization', 'Mcp-Session-Id'],
|
||||
allowedHeaders: ['Content-Type', 'X-Acai-Token', 'X-Acai-Website', 'X-Acai-Token-Hash', 'X-User-Token', 'X-Project-Name', 'X-Acai-User', 'Authorization', 'Mcp-Session-Id'],
|
||||
exposedHeaders: ['Mcp-Session-Id'],
|
||||
credentials: true
|
||||
}));
|
||||
@@ -209,6 +220,29 @@ export function startHttpServer() {
|
||||
// Extract credentials from request
|
||||
const credentials = extractCredentialsFromRequest(req);
|
||||
|
||||
// Si la request trae projectName, resolvemos credenciales ANTES de
|
||||
// crear el transport. Si falla (p.ej. proyecto no existe o no
|
||||
// pertenece al usuario), propagamos el error al cliente MCP en
|
||||
// lugar de enmascararlo como initialize OK.
|
||||
let resolvedCreds = null;
|
||||
if (credentials.projectName) {
|
||||
try {
|
||||
resolvedCreds = await resolveProjectCredentials(credentials.projectName, credentials.acaiUser);
|
||||
} catch (err) {
|
||||
const status = err.status === 404 ? 404 : 400;
|
||||
console.error(`[MCP Streamable] Credential resolution failed (user=${credentials.acaiUser || "-"}, project=${credentials.projectName}): ${err.message}`);
|
||||
res.status(status).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: status === 404 ? -32004 : -32000,
|
||||
message: err.message,
|
||||
},
|
||||
id: req.body?.id ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
onsessioninitialized: (sessionId) => {
|
||||
@@ -222,18 +256,36 @@ export function startHttpServer() {
|
||||
startTime: Date.now()
|
||||
});
|
||||
|
||||
// Configure credentials for this session (async, fire-and-forget)
|
||||
// Guardar credenciales ya resueltas, o caer a otros metodos
|
||||
// (token directo, userToken) de forma sincrona.
|
||||
if (resolvedCreds) {
|
||||
const creds = {
|
||||
token: resolvedCreds.token,
|
||||
tokenHash: resolvedCreds.tokenHash || null,
|
||||
website: resolvedCreds.website,
|
||||
web_url: resolvedCreds.web_url,
|
||||
api_web_url: resolvedCreds.api_web_url || resolvedCreds.web_url,
|
||||
forge_host: resolvedCreds.forge_host || null,
|
||||
mode: resolvedCreds.mode || "local",
|
||||
project_dir: resolvedCreds.project_dir || null,
|
||||
acai_user: credentials.acaiUser || resolvedCreds.acai_user || null,
|
||||
profileName: 'project-' + credentials.projectName,
|
||||
role: 'developer',
|
||||
};
|
||||
sessionCredentials.set(sessionId, creds);
|
||||
setMcpSessionCredentials(sessionId, creds);
|
||||
console.log(`[MCP] Session ${sessionId} authenticated via project '${credentials.projectName}' (user=${credentials.acaiUser || "-"}) - web_url: ${creds.web_url}`);
|
||||
} else {
|
||||
// Sin projectName -> usar fallback de credenciales directas
|
||||
configureSessionCredentials(sessionId, credentials).then((configured) => {
|
||||
if (configured) {
|
||||
// Also store credentials by MCP-Session-Id for persistence
|
||||
const creds = sessionCredentials.get(sessionId);
|
||||
if (creds) {
|
||||
setMcpSessionCredentials(sessionId, creds);
|
||||
}
|
||||
if (creds) setMcpSessionCredentials(sessionId, creds);
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error(`[MCP Streamable] Error configuring credentials for session ${sessionId}:`, err.message);
|
||||
});
|
||||
}
|
||||
},
|
||||
onsessionclosed: (sessionId) => {
|
||||
console.log(`[MCP Streamable] Session closed: ${sessionId.substring(0, 8)}...`);
|
||||
@@ -297,8 +349,21 @@ export function startHttpServer() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle the request with the transport
|
||||
// Resolver el mcpSessionId definitivo para propagar via AsyncLocalStorage.
|
||||
// En initialize hay que esperar a que el transport asigne el sessionId
|
||||
// (lo hace sincronicamente en handleRequest via onsessioninitialized).
|
||||
const resolvedSessionId = mcpSessionId || transport.sessionId || null;
|
||||
|
||||
if (resolvedSessionId) {
|
||||
await runWithSession(resolvedSessionId, async () => {
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
});
|
||||
} else {
|
||||
// En la request de initialize todavia no existe sessionId — el
|
||||
// transport lo genera internamente. Las tools no se llaman en
|
||||
// initialize, por lo que podemos invocar sin contexto.
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MCP Streamable] Error:', error);
|
||||
if (!res.headersSent) {
|
||||
@@ -351,8 +416,20 @@ export function startHttpServer() {
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Configure credentials
|
||||
// Configure credentials (si falla devolvemos 4xx antes de abrir el SSE)
|
||||
try {
|
||||
await configureSessionCredentials(sessionId, credentials);
|
||||
} catch (err) {
|
||||
const status = err.status === 404 ? 404 : 400;
|
||||
console.error(`[MCP SSE] Credential resolution failed: ${err.message}`);
|
||||
clearInterval(heartbeatInterval);
|
||||
if (!res.headersSent) {
|
||||
res.status(status).json({ error: err.message });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Store session
|
||||
activeSessions.set(sessionId, {
|
||||
@@ -421,7 +498,9 @@ export function startHttpServer() {
|
||||
}
|
||||
|
||||
try {
|
||||
await runWithSession(sessionId, async () => {
|
||||
await transport.handlePostMessage(req, res, req.body);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[MCP SSE] POST error for session ${sessionId}:`, error.message);
|
||||
if (!res.headersSent) {
|
||||
|
||||
@@ -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 = "";
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(acaiFilePath, "utf-8"));
|
||||
token = data.token || "";
|
||||
tokenHash = data.tokenHash || "";
|
||||
domain = data.domain || "";
|
||||
} catch (e) {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ success: false, error: `Cannot read .acai: ${e.message}` }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const acaiUser = resolveCurrentAcaiUser();
|
||||
|
||||
// Step 2: Check if token is expired by decoding JWT
|
||||
let isExpired = false;
|
||||
// Delegamos al Python que ya gestiona expiracion + refresh + persistencia
|
||||
let info;
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
info = await fetchProjectInfo({ project_dir: projectDir }, acaiUser);
|
||||
} catch (e) {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ success: false, error: `Token refresh failed: ${e.message}` }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!info?.success) {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ success: false, error: info?.error || "Project info resolution failed" }) }],
|
||||
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 response = await axios.post(`${PYTHON_BASE}${path}`, data, {
|
||||
headers: {
|
||||
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: 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" }],
|
||||
|
||||
32
mcp-server/utils/sessionContext.js
Normal file
32
mcp-server/utils/sessionContext.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* AsyncLocalStorage-based propagation del mcpSessionId a los handlers de las tools.
|
||||
*
|
||||
* En el transporte HTTP, el servidor MCP multiplexa muchas sesiones concurrentes
|
||||
* en un mismo proceso Node. Como los handlers de las tools no reciben el
|
||||
* `mcp-session-id` directamente (solo lo que el SDK les pase en `extra`), usamos
|
||||
* AsyncLocalStorage para propagar el identificador desde el handler HTTP
|
||||
* (`POST /mcp`, `GET /sse`, `POST /message`) hasta la tool, y de ahi a helpers
|
||||
* como `getCurrentProjectInfo` que resuelven `project_dir` desde las
|
||||
* credenciales de la sesion (no desde `process.env`).
|
||||
*
|
||||
* En modo stdio no hace falta: cada subprocess tiene su propio env.
|
||||
*/
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
export const sessionContext = new AsyncLocalStorage();
|
||||
|
||||
/**
|
||||
* Envuelve la ejecucion de `fn` dentro del contexto de la sesion `mcpSessionId`.
|
||||
* Cualquier llamada a `getCurrentSessionId()` dentro de `fn` (incluidas las
|
||||
* callbacks async) devolvera ese id.
|
||||
*/
|
||||
export function runWithSession(mcpSessionId, fn) {
|
||||
return sessionContext.run({ mcpSessionId }, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve el mcpSessionId activo, o null si no estamos dentro de runWithSession.
|
||||
*/
|
||||
export function getCurrentSessionId() {
|
||||
return sessionContext.getStore()?.mcpSessionId || null;
|
||||
}
|
||||
Reference in New Issue
Block a user