cambios mcp remoto
This commit is contained in:
@@ -105,6 +105,7 @@ const readProjectAcaiFallback = () => {
|
|||||||
forge_host: forgeHost,
|
forge_host: forgeHost,
|
||||||
tokenHash: data.tokenHash || process.env.ACAI_TOKEN_HASH || null,
|
tokenHash: data.tokenHash || process.env.ACAI_TOKEN_HASH || null,
|
||||||
mode,
|
mode,
|
||||||
|
project_dir: projectDir || null,
|
||||||
profileName: "acai-file",
|
profileName: "acai-file",
|
||||||
role: resolveEffectiveRole(data.role),
|
role: resolveEffectiveRole(data.role),
|
||||||
};
|
};
|
||||||
@@ -142,6 +143,7 @@ const resolveLocalProjectFallback = async () => {
|
|||||||
forge_host: forgeHost,
|
forge_host: forgeHost,
|
||||||
tokenHash: info.tokenHash || process.env.ACAI_TOKEN_HASH || null,
|
tokenHash: info.tokenHash || process.env.ACAI_TOKEN_HASH || null,
|
||||||
mode,
|
mode,
|
||||||
|
project_dir: info.project_dir || projectDir || null,
|
||||||
profileName: "project-info",
|
profileName: "project-info",
|
||||||
role: resolveEffectiveRole(info.role),
|
role: resolveEffectiveRole(info.role),
|
||||||
};
|
};
|
||||||
@@ -313,8 +315,8 @@ export const setSessionUserToken = (userToken, sessionId) => {
|
|||||||
* @param {string} sessionId - The session ID (SSE transport session)
|
* @param {string} sessionId - The session ID (SSE transport session)
|
||||||
* @param {string} mcpSessionId - Optional MCP-Session-Id for persistence across SSE reconnections
|
* @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) => {
|
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'}, hasMcpSessionId=${!!mcpSessionId}`);
|
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 = {
|
const creds = {
|
||||||
website,
|
website,
|
||||||
@@ -323,6 +325,7 @@ export const setCredentials = async ({ website, web_url, api_web_url, forge_host
|
|||||||
forge_host: forge_host || null,
|
forge_host: forge_host || null,
|
||||||
token,
|
token,
|
||||||
tokenHash,
|
tokenHash,
|
||||||
|
project_dir: project_dir || null,
|
||||||
profileName: profileName || "manual",
|
profileName: profileName || "manual",
|
||||||
role: role || DEFAULT_ROLE,
|
role: role || DEFAULT_ROLE,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../config/index.js";
|
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 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`, {
|
const response = await axios.get(`${LOCAL_SERVER_URL}/api/project-info`, {
|
||||||
params,
|
params,
|
||||||
headers: getLocalServerHeaders(),
|
headers,
|
||||||
});
|
});
|
||||||
return response.data; // { success, web_url, token, tokenHash, domain, project_dir }
|
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`, {
|
const response = await axios.get(`${LOCAL_SERVER_URL}/api/mcp/projects`, {
|
||||||
headers: getLocalServerHeaders(),
|
headers,
|
||||||
});
|
});
|
||||||
return response.data; // { success, projects: [...] }
|
return response.data; // { success, projects: [...] }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "./auth/index.js";
|
} from "./auth/index.js";
|
||||||
import { fetchProjectInfo } from "./auth/localClient.js";
|
import { fetchProjectInfo } from "./auth/localClient.js";
|
||||||
import { createSessionServer } from "./server.js";
|
import { createSessionServer } from "./server.js";
|
||||||
|
import { runWithSession } from "./utils/sessionContext.js";
|
||||||
|
|
||||||
// Active sessions - stores { transport, server, type, heartbeatInterval }
|
// Active sessions - stores { transport, server, type, heartbeatInterval }
|
||||||
const activeSessions = new Map();
|
const activeSessions = new Map();
|
||||||
@@ -72,9 +73,9 @@ const verifyJwt = (token) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveProjectCredentials = async (projectName) => {
|
const resolveProjectCredentials = async (projectName, acaiUser = null) => {
|
||||||
try {
|
try {
|
||||||
const info = await fetchProjectInfo(projectName);
|
const info = await fetchProjectInfo(projectName, acaiUser);
|
||||||
if (!info.success) {
|
if (!info.success) {
|
||||||
throw new Error(info.error || "Failed to resolve project info");
|
throw new Error(info.error || "Failed to resolve project info");
|
||||||
}
|
}
|
||||||
@@ -86,37 +87,44 @@ const resolveProjectCredentials = async (projectName) => {
|
|||||||
api_web_url: info.api_web_url || info.web_url,
|
api_web_url: info.api_web_url || info.web_url,
|
||||||
forge_host: info.forge_host || null,
|
forge_host: info.forge_host || null,
|
||||||
mode: info.mode || "local",
|
mode: info.mode || "local",
|
||||||
|
project_dir: info.project_dir || null,
|
||||||
|
acai_user: acaiUser || info.acai_user || null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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
|
* 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
|
// Priority 1: Resolve via project name from local Python server
|
||||||
if (projectName) {
|
if (projectName) {
|
||||||
try {
|
const projectCreds = await resolveProjectCredentials(projectName, acaiUser);
|
||||||
const projectCreds = await resolveProjectCredentials(projectName);
|
sessionCredentials.set(sessionId, {
|
||||||
sessionCredentials.set(sessionId, {
|
token: projectCreds.token,
|
||||||
token: projectCreds.token,
|
tokenHash: projectCreds.tokenHash || null,
|
||||||
tokenHash: projectCreds.tokenHash || null,
|
website: projectCreds.website,
|
||||||
website: projectCreds.website,
|
web_url: projectCreds.web_url,
|
||||||
web_url: projectCreds.web_url,
|
api_web_url: projectCreds.api_web_url || projectCreds.web_url,
|
||||||
api_web_url: projectCreds.api_web_url || projectCreds.web_url,
|
forge_host: projectCreds.forge_host || null,
|
||||||
forge_host: projectCreds.forge_host || null,
|
mode: projectCreds.mode || "local",
|
||||||
mode: projectCreds.mode || "local",
|
project_dir: projectCreds.project_dir || null,
|
||||||
profileName: 'project-' + projectName,
|
acai_user: acaiUser || projectCreds.acai_user || null,
|
||||||
role: 'developer',
|
profileName: 'project-' + projectName,
|
||||||
});
|
role: 'developer',
|
||||||
console.log(`[MCP] Session ${sessionId} authenticated via project '${projectName}' - web_url: ${projectCreds.web_url}`);
|
});
|
||||||
return true;
|
console.log(`[MCP] Session ${sessionId} authenticated via project '${projectName}' (user=${acaiUser || "-"}) - web_url: ${projectCreds.web_url}`);
|
||||||
} catch (error) {
|
return true;
|
||||||
console.error(`[MCP] Failed to resolve project '${projectName}': ${error.message}`);
|
|
||||||
// Fall through to try other auth methods
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: Direct credentials (legacy header-based)
|
// Priority 2: Direct credentials (legacy header-based)
|
||||||
@@ -151,7 +159,10 @@ const extractCredentialsFromRequest = (req) => {
|
|||||||
token: url.searchParams.get('token') || req.headers['x-acai-token'],
|
token: url.searchParams.get('token') || req.headers['x-acai-token'],
|
||||||
tokenHash: url.searchParams.get('tokenHash') || req.headers['x-acai-token-hash'],
|
tokenHash: url.searchParams.get('tokenHash') || req.headers['x-acai-token-hash'],
|
||||||
website: url.searchParams.get('website') || req.headers['x-acai-website'],
|
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({
|
app.use(cors({
|
||||||
origin: '*',
|
origin: '*',
|
||||||
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
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'],
|
exposedHeaders: ['Mcp-Session-Id'],
|
||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
@@ -209,6 +220,29 @@ export function startHttpServer() {
|
|||||||
// Extract credentials from request
|
// Extract credentials from request
|
||||||
const credentials = extractCredentialsFromRequest(req);
|
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({
|
transport = new StreamableHTTPServerTransport({
|
||||||
sessionIdGenerator: () => crypto.randomUUID(),
|
sessionIdGenerator: () => crypto.randomUUID(),
|
||||||
onsessioninitialized: (sessionId) => {
|
onsessioninitialized: (sessionId) => {
|
||||||
@@ -222,18 +256,36 @@ export function startHttpServer() {
|
|||||||
startTime: Date.now()
|
startTime: Date.now()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Configure credentials for this session (async, fire-and-forget)
|
// Guardar credenciales ya resueltas, o caer a otros metodos
|
||||||
configureSessionCredentials(sessionId, credentials).then((configured) => {
|
// (token directo, userToken) de forma sincrona.
|
||||||
if (configured) {
|
if (resolvedCreds) {
|
||||||
// Also store credentials by MCP-Session-Id for persistence
|
const creds = {
|
||||||
const creds = sessionCredentials.get(sessionId);
|
token: resolvedCreds.token,
|
||||||
if (creds) {
|
tokenHash: resolvedCreds.tokenHash || null,
|
||||||
setMcpSessionCredentials(sessionId, creds);
|
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) {
|
||||||
|
const creds = sessionCredentials.get(sessionId);
|
||||||
|
if (creds) setMcpSessionCredentials(sessionId, creds);
|
||||||
}
|
}
|
||||||
}
|
}).catch((err) => {
|
||||||
}).catch((err) => {
|
console.error(`[MCP Streamable] Error configuring credentials for session ${sessionId}:`, err.message);
|
||||||
console.error(`[MCP Streamable] Error configuring credentials for session ${sessionId}:`, err.message);
|
});
|
||||||
});
|
}
|
||||||
},
|
},
|
||||||
onsessionclosed: (sessionId) => {
|
onsessionclosed: (sessionId) => {
|
||||||
console.log(`[MCP Streamable] Session closed: ${sessionId.substring(0, 8)}...`);
|
console.log(`[MCP Streamable] Session closed: ${sessionId.substring(0, 8)}...`);
|
||||||
@@ -297,8 +349,21 @@ export function startHttpServer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle the request with the transport
|
// Resolver el mcpSessionId definitivo para propagar via AsyncLocalStorage.
|
||||||
await transport.handleRequest(req, res, req.body);
|
// 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) {
|
} catch (error) {
|
||||||
console.error('[MCP Streamable] Error:', error);
|
console.error('[MCP Streamable] Error:', error);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
@@ -351,8 +416,20 @@ export function startHttpServer() {
|
|||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
// Configure credentials
|
// Configure credentials (si falla devolvemos 4xx antes de abrir el SSE)
|
||||||
await configureSessionCredentials(sessionId, credentials);
|
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
|
// Store session
|
||||||
activeSessions.set(sessionId, {
|
activeSessions.set(sessionId, {
|
||||||
@@ -421,7 +498,9 @@ export function startHttpServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await transport.handlePostMessage(req, res, req.body);
|
await runWithSession(sessionId, async () => {
|
||||||
|
await transport.handlePostMessage(req, res, req.body);
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[MCP SSE] POST error for session ${sessionId}:`, error.message);
|
console.error(`[MCP SSE] POST error for session ${sessionId}:`, error.message);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
|
|||||||
@@ -1,97 +1,90 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import fs from "fs";
|
import { sessionCredentials, setMcpSessionCredentials } from "../../auth/credentials.js";
|
||||||
import path from "path";
|
|
||||||
import { sessionCredentials } from "../../auth/credentials.js";
|
|
||||||
import { withAuthParams } from "../helpers/authSchema.js";
|
import { withAuthParams } from "../helpers/authSchema.js";
|
||||||
import { fetchProjectInfo } from "../../auth/localClient.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) {
|
export function registerAuthTools(server) {
|
||||||
server.tool(
|
server.tool(
|
||||||
"refresh_acai_token",
|
"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({}),
|
withAuthParams({}),
|
||||||
{ readOnlyHint: false, destructiveHint: false },
|
{ readOnlyHint: false, destructiveHint: false },
|
||||||
async (_args, extra) => {
|
async (_args, extra) => {
|
||||||
try {
|
try {
|
||||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
const projectDir = resolveCurrentProjectDir();
|
||||||
const acaiFilePath = projectDir ? path.join(projectDir, ".acai") : "";
|
if (!projectDir) {
|
||||||
|
|
||||||
if (!acaiFilePath) {
|
|
||||||
return {
|
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,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Try reading fresh token from .acai (Python server may have already refreshed it)
|
const acaiUser = resolveCurrentAcaiUser();
|
||||||
let token = "";
|
|
||||||
let tokenHash = "";
|
// Delegamos al Python que ya gestiona expiracion + refresh + persistencia
|
||||||
let domain = "";
|
let info;
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(fs.readFileSync(acaiFilePath, "utf-8"));
|
info = await fetchProjectInfo({ project_dir: projectDir }, acaiUser);
|
||||||
token = data.token || "";
|
|
||||||
tokenHash = data.tokenHash || "";
|
|
||||||
domain = data.domain || "";
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
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,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Check if token is expired by decoding JWT
|
if (!info?.success) {
|
||||||
let isExpired = false;
|
return {
|
||||||
try {
|
content: [{ type: "text", text: JSON.stringify({ success: false, error: info?.error || "Project info resolution failed" }) }],
|
||||||
const payload = token.split(".")[1];
|
isError: true,
|
||||||
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
|
// Comparamos token previo para saber si hubo renovacion
|
||||||
if (isExpired) {
|
const mcpSessionId = getCurrentSessionId();
|
||||||
try {
|
let previousToken = null;
|
||||||
const info = await fetchProjectInfo({ project_dir: projectDir });
|
if (mcpSessionId) {
|
||||||
token = info?.token || token;
|
// Leer creds previas sin tocar lastAccess via interno no expuesto:
|
||||||
tokenHash = info?.tokenHash || tokenHash;
|
// usamos sessionCredentials como espejo si existe, sino null.
|
||||||
domain = info?.domain || domain;
|
const prev = sessionCredentials.get(mcpSessionId);
|
||||||
} catch (e) {
|
previousToken = prev?.token || null;
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: JSON.stringify({ success: false, error: `Token refresh failed: ${e.message}` }) }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 = {
|
const freshCreds = {
|
||||||
token,
|
token: info.token || "",
|
||||||
tokenHash,
|
tokenHash: info.tokenHash || "",
|
||||||
website,
|
website: info.domain || "",
|
||||||
web_url: webUrl,
|
web_url: info.web_url || "",
|
||||||
api_web_url: apiWebUrl,
|
api_web_url: info.api_web_url || info.web_url || "",
|
||||||
forge_host: info?.forge_host || null,
|
forge_host: info.forge_host || null,
|
||||||
profileName: "stdio",
|
project_dir: info.project_dir || projectDir,
|
||||||
|
acai_user: acaiUser || null,
|
||||||
|
profileName: acaiUser || "mcp-session",
|
||||||
role: "developer",
|
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) {
|
if (extra?.sessionId) {
|
||||||
sessionCredentials.set(extra.sessionId, freshCreds);
|
sessionCredentials.set(extra.sessionId, freshCreds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rotated = previousToken && previousToken !== freshCreds.token;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify({
|
text: JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Token refreshed successfully",
|
message: rotated ? "Token refreshed (rotated by Python)" : "Token refreshed successfully",
|
||||||
expired_before: isExpired,
|
domain: freshCreds.website,
|
||||||
domain: website,
|
rotated: !!rotated,
|
||||||
}, null, 2),
|
}, null, 2),
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,31 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../../config/index.js";
|
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() {
|
export function getCurrentProjectInfo() {
|
||||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
const projectDir = resolveCurrentProjectDir();
|
||||||
if (!projectDir) {
|
if (!projectDir) {
|
||||||
throw new Error("ACAI_PROJECT_DIR not set");
|
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) {
|
export async function callLocalFileEndpoint(method, endpoint, payload = null, query = null) {
|
||||||
const headers = getLocalServerHeaders();
|
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") {
|
if (method === "GET") {
|
||||||
const response = await axios.get(`${LOCAL_SERVER_URL}${endpoint}`, {
|
const response = await axios.get(`${LOCAL_SERVER_URL}${endpoint}`, {
|
||||||
params: query || undefined,
|
params: query || undefined,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { resolveCurrentProjectDir } from '../files/helpers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the current user has write access to a table.
|
* 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.
|
* en lugar del .acai para determinar el modo.
|
||||||
*/
|
*/
|
||||||
export function canAccessTable(tableName) {
|
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
|
if (!projectDir) return { allowed: true }; // no project dir, don't block
|
||||||
|
|
||||||
const acaiFile = path.join(projectDir, ".acai");
|
const acaiFile = path.join(projectDir, ".acai");
|
||||||
|
|||||||
@@ -1,21 +1,45 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { resolveCurrentAcaiUser } from "./sessionHelpers.js";
|
||||||
|
|
||||||
const PYTHON_BASE = `http://app:${process.env.ACAI_PORT || 9091}`;
|
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 authHeader = process.env.ACAI_AUTH_HEADER || "";
|
||||||
const mode = process.env.ACAI_MODE_OVERRIDE || process.env.ACAI_MODE || "";
|
const mode = process.env.ACAI_MODE_OVERRIDE || process.env.ACAI_MODE || "";
|
||||||
const role = process.env.ACAI_ROLE_OVERRIDE || "";
|
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, {
|
const response = await axios.post(`${PYTHON_BASE}${path}`, data, {
|
||||||
headers: {
|
headers: buildPythonHeaders(),
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(authHeader ? { "Authorization": authHeader } : {}),
|
|
||||||
...(mode ? { "X-Acai-Mode": mode } : {}),
|
|
||||||
...(role ? { "X-Acai-Role": role } : {}),
|
|
||||||
},
|
|
||||||
timeout,
|
timeout,
|
||||||
maxBodyLength: Infinity,
|
maxBodyLength: Infinity,
|
||||||
maxContentLength: Infinity,
|
maxContentLength: Infinity,
|
||||||
});
|
});
|
||||||
return response.data;
|
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 { handleToolError } from "../helpers/errorHandler.js";
|
||||||
import { withAuthParams } from "../helpers/authSchema.js";
|
import { withAuthParams } from "../helpers/authSchema.js";
|
||||||
import { pythonPost } from "../helpers/pythonServerClient.js";
|
import { pythonPost } from "../helpers/pythonServerClient.js";
|
||||||
|
import { resolveCurrentProjectDir } from "../files/helpers.js";
|
||||||
|
|
||||||
// --- Verificacion de creditos ---
|
// --- Verificacion de creditos ---
|
||||||
const WS_BASE = "https://ws.cocosolution.com/api/handler_acaicode.php";
|
const WS_BASE = "https://ws.cocosolution.com/api/handler_acaicode.php";
|
||||||
|
|
||||||
function getAcaiToken() {
|
function getAcaiToken() {
|
||||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
const projectDir = resolveCurrentProjectDir();
|
||||||
if (!projectDir) return null;
|
if (!projectDir) return null;
|
||||||
try {
|
try {
|
||||||
const acaiFile = path.join(projectDir, ".acai");
|
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 safeFileName = fileName || `generated-${Date.now()}`;
|
||||||
const destRelativePath = `cms/uploads/generated/${safeFileName}.jpg`;
|
const destRelativePath = `cms/uploads/generated/${safeFileName}.jpg`;
|
||||||
const fullPrompt = style ? `${style} style: ${prompt}` : prompt;
|
const fullPrompt = style ? `${style} style: ${prompt}` : prompt;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { handleToolError, validateRequired, handleApiResponse } from "../helpers
|
|||||||
import { withAuthParams } from "../helpers/authSchema.js";
|
import { withAuthParams } from "../helpers/authSchema.js";
|
||||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
||||||
import { pythonPost } from "../helpers/pythonServerClient.js";
|
import { pythonPost } from "../helpers/pythonServerClient.js";
|
||||||
|
import { resolveCurrentProjectDir } from "../files/helpers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: POST to mcp_respond.php via viewer_functions.php
|
* 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
|
// 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) {
|
if (projectDir && parsed.pathname) {
|
||||||
try {
|
try {
|
||||||
const localPath = path.join(projectDir, parsed.pathname);
|
const localPath = path.join(projectDir, parsed.pathname);
|
||||||
@@ -118,7 +119,7 @@ export function registerUploadRecordImageTool(server) {
|
|||||||
);
|
);
|
||||||
if (validationError) return validationError;
|
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)
|
// Intentar via Python server (tiene sync + optimizacion)
|
||||||
let result;
|
let result;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
|||||||
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||||
import { withAuthParams } from "../helpers/authSchema.js";
|
import { withAuthParams } from "../helpers/authSchema.js";
|
||||||
import { LOCAL_SERVER_URL } from "../../config/index.js";
|
import { LOCAL_SERVER_URL } from "../../config/index.js";
|
||||||
|
import { resolveCurrentAcaiUser } from "../helpers/sessionHelpers.js";
|
||||||
|
|
||||||
export function registerCompileModuleTool(server) {
|
export function registerCompileModuleTool(server) {
|
||||||
server.tool(
|
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 }
|
? { project: projectSlug, relativePath, project_dir: projectDir }
|
||||||
: { file: filePath, 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(
|
const response = await axios.post(
|
||||||
`${LOCAL_SERVER_URL}/api/compile-module`,
|
`${LOCAL_SERVER_URL}/api/compile-module`,
|
||||||
payload,
|
payload,
|
||||||
{ headers: { "Content-Type": "application/json" }, timeout: 30000 }
|
{ headers, timeout: 30000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.data?.ok) {
|
if (response.data?.ok) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { withAuth } from "../../auth/index.js";
|
|||||||
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||||
import { withAuthParams } from "../helpers/authSchema.js";
|
import { withAuthParams } from "../helpers/authSchema.js";
|
||||||
import { LOCAL_SERVER_URL } from "../../config/index.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) {
|
export function registerCreateModuleTool(server) {
|
||||||
server.tool(
|
server.tool(
|
||||||
@@ -35,7 +37,7 @@ Parameters:
|
|||||||
const validationError = validateRequired({ moduleId, html }, ['moduleId', 'html'], 'create_module');
|
const validationError = validateRequired({ moduleId, html }, ['moduleId', 'html'], 'create_module');
|
||||||
if (validationError) return validationError;
|
if (validationError) return validationError;
|
||||||
|
|
||||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
const projectDir = resolveCurrentProjectDir();
|
||||||
if (!projectDir) {
|
if (!projectDir) {
|
||||||
return { content: [{ type: "text", text: "Error: ACAI_PROJECT_DIR not set" }], isError: true };
|
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.toLowerCase().replace(/\s+/g, '_'); // Ensure moduleId is lowercase and uses underscores
|
||||||
moduleId = moduleId + "_" + (Math.random().toString(36).substring(2, 8).toUpperCase());
|
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(
|
const response = await axios.post(
|
||||||
`${LOCAL_SERVER_URL}/api/create-module`,
|
`${LOCAL_SERVER_URL}/api/create-module`,
|
||||||
{ project_dir: projectDir, module_id: moduleId, html, css: css || "", js: js || "", label, description, php: php || "" },
|
{ 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) {
|
if (response.data?.success) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
|||||||
import { handleToolError } from "../helpers/errorHandler.js";
|
import { handleToolError } from "../helpers/errorHandler.js";
|
||||||
import { withAuthParams } from "../helpers/authSchema.js";
|
import { withAuthParams } from "../helpers/authSchema.js";
|
||||||
import { LOCAL_SERVER_URL } from "../../config/index.js";
|
import { LOCAL_SERVER_URL } from "../../config/index.js";
|
||||||
|
import { resolveCurrentAcaiUser } from "../helpers/sessionHelpers.js";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
export function registerNavigateBrowserTool(server) {
|
export function registerNavigateBrowserTool(server) {
|
||||||
@@ -30,12 +31,16 @@ export function registerNavigateBrowserTool(server) {
|
|||||||
const credentials = await getSessionCredentials(extra.sessionId);
|
const credentials = await getSessionCredentials(extra.sessionId);
|
||||||
const project = credentials.website || process.env.ACAI_WEBSITE || "";
|
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`, {
|
await axios.post(`${LOCAL_SERVER_URL}/api/browser/navigate`, {
|
||||||
project: project,
|
project: project,
|
||||||
enlace: enlace,
|
enlace: enlace,
|
||||||
}, {
|
}, {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers,
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { handleToolError } from "../helpers/errorHandler.js";
|
|||||||
import { withAuthParams } from "../helpers/authSchema.js";
|
import { withAuthParams } from "../helpers/authSchema.js";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { resolveCurrentProjectDir } from "../files/helpers.js";
|
||||||
|
|
||||||
export function registerSaveProjectStylesTool(server) {
|
export function registerSaveProjectStylesTool(server) {
|
||||||
server.tool(
|
server.tool(
|
||||||
@@ -24,8 +25,8 @@ The content should include: color palette (hex values), typography, spacing patt
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get project directory from env
|
// Get project directory from session (HTTP) or env (stdio fallback)
|
||||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
const projectDir = resolveCurrentProjectDir();
|
||||||
if (!projectDir) {
|
if (!projectDir) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: "Error: ACAI_PROJECT_DIR not set" }],
|
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