cambios mcp remoto

This commit is contained in:
Jordan Diaz
2026-04-17 20:03:02 +00:00
parent d41a94b57d
commit 2ac01acd61
15 changed files with 344 additions and 123 deletions

View File

@@ -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,37 +87,44 @@ 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);
sessionCredentials.set(sessionId, {
token: projectCreds.token,
tokenHash: projectCreds.tokenHash || null,
website: projectCreds.website,
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',
});
console.log(`[MCP] Session ${sessionId} authenticated via project '${projectName}' - 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
}
const projectCreds = await resolveProjectCredentials(projectName, acaiUser);
sessionCredentials.set(sessionId, {
token: projectCreds.token,
tokenHash: projectCreds.tokenHash || null,
website: projectCreds.website,
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",
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}' (user=${acaiUser || "-"}) - web_url: ${projectCreds.web_url}`);
return true;
}
// 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)
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);
// 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) {
const creds = sessionCredentials.get(sessionId);
if (creds) setMcpSessionCredentials(sessionId, creds);
}
}
}).catch((err) => {
console.error(`[MCP Streamable] Error configuring credentials for session ${sessionId}:`, err.message);
});
}).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
await transport.handleRequest(req, res, req.body);
// 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
await configureSessionCredentials(sessionId, 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 transport.handlePostMessage(req, res, req.body);
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) {