fix MCP auth token refresh and disable legacy SSE
This commit is contained in:
@@ -2,8 +2,30 @@ import axios from "axios";
|
|||||||
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { sessionApiClients, getSessionCredentials, setCredentials, findRoleByToken } from "./credentials.js";
|
import { sessionApiClients, getSessionCredentials, setCredentials, findRoleByToken } from "./credentials.js";
|
||||||
import { assertSafeCmsTarget } from "../utils/cmsTargetSafety.js";
|
import { assertSafeCmsTarget } from "../utils/cmsTargetSafety.js";
|
||||||
|
import { ensureFreshSessionCredentials, refreshSessionCredentials } from "./sessionRefresh.js";
|
||||||
const DEFAULT_ROLE = 'developer';
|
const DEFAULT_ROLE = 'developer';
|
||||||
|
|
||||||
|
export const isAcaiTokenFailure = (error) => {
|
||||||
|
const status = error?.response?.status;
|
||||||
|
const responseText = JSON.stringify(error?.response?.data || "");
|
||||||
|
return status === 401
|
||||||
|
|| (status === 403 && /token|jwt|auth|unauthoriz|expired|no v[aá]lido/i.test(responseText));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runWithTokenRefreshRetry = async (
|
||||||
|
sessionId,
|
||||||
|
operation,
|
||||||
|
{ refreshCredentials = refreshSessionCredentials } = {},
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isAcaiTokenFailure(error) || error?.config?._acaiTokenRetry) throw error;
|
||||||
|
await refreshCredentials(sessionId, { force: true });
|
||||||
|
return operation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if session is configured with valid credentials
|
* Check if session is configured with valid credentials
|
||||||
*/
|
*/
|
||||||
@@ -29,7 +51,7 @@ export const ensureConfigured = async (sessionId) => {
|
|||||||
/**
|
/**
|
||||||
* Rebuild API client for a session
|
* Rebuild API client for a session
|
||||||
*/
|
*/
|
||||||
export const rebuildApiClient = async (sessionId) => {
|
export const rebuildApiClient = async (sessionId, { refreshCredentials = refreshSessionCredentials } = {}) => {
|
||||||
const creds = await getSessionCredentials(sessionId);
|
const creds = await getSessionCredentials(sessionId);
|
||||||
if (!creds.token || !creds.web_url || !creds.api_web_url) {
|
if (!creds.token || !creds.web_url || !creds.api_web_url) {
|
||||||
return null;
|
return null;
|
||||||
@@ -44,14 +66,29 @@ export const rebuildApiClient = async (sessionId) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Request interceptor: always send latest token
|
// Always resolve the latest session token. The client outlives individual
|
||||||
client.interceptors.request.use((config) => {
|
// JWTs, so capturing `creds` here would keep sending a rotated token.
|
||||||
if (creds.token) {
|
client.interceptors.request.use(async (config) => {
|
||||||
config.headers["X-Acai-Token"] = creds.token;
|
const latest = await getSessionCredentials(sessionId);
|
||||||
|
if (latest.token) {
|
||||||
|
config.headers["X-Acai-Token"] = latest.token;
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
client.interceptors.response.use(undefined, async (error) => {
|
||||||
|
const original = error.config;
|
||||||
|
if (!isAcaiTokenFailure(error) || !original || original._acaiTokenRetry) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
original._acaiTokenRetry = true;
|
||||||
|
const fresh = await refreshCredentials(sessionId, { force: true });
|
||||||
|
original.headers = original.headers || {};
|
||||||
|
original.headers["X-Acai-Token"] = fresh.token;
|
||||||
|
return client.request(original);
|
||||||
|
});
|
||||||
|
|
||||||
sessionApiClients.set(sessionId, client);
|
sessionApiClients.set(sessionId, client);
|
||||||
return client;
|
return client;
|
||||||
};
|
};
|
||||||
@@ -133,10 +170,19 @@ export const withAuth = (handler) => {
|
|||||||
}, sessionId);
|
}, sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ensureFreshSessionCredentials(sessionId);
|
||||||
console.error(`[withAuth] Getting API client for session ${sessionId}...`);
|
console.error(`[withAuth] Getting API client for session ${sessionId}...`);
|
||||||
await getApiClient(sessionId);
|
await getApiClient(sessionId);
|
||||||
console.error(`[withAuth] API client ready, calling handler...`);
|
console.error(`[withAuth] API client ready, calling handler...`);
|
||||||
|
|
||||||
return handler(args, { ...extra, sessionId, inlineCredentials: hasInlineCredentials ? inlineCredentials : null });
|
const handlerExtra = {
|
||||||
|
...extra,
|
||||||
|
sessionId,
|
||||||
|
inlineCredentials: hasInlineCredentials ? inlineCredentials : null,
|
||||||
|
};
|
||||||
|
return runWithTokenRefreshRetry(
|
||||||
|
sessionId,
|
||||||
|
() => handler(args, handlerExtra),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const cleanupExpiredMcpSessions = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Run cleanup every 5 minutes
|
// Run cleanup every 5 minutes
|
||||||
setInterval(cleanupExpiredMcpSessions, 5 * 60 * 1000);
|
setInterval(cleanupExpiredMcpSessions, 5 * 60 * 1000).unref();
|
||||||
|
|
||||||
const buildApiUrlFromPublicUrl = (webUrl, explicitForgeHost = "") => {
|
const buildApiUrlFromPublicUrl = (webUrl, explicitForgeHost = "") => {
|
||||||
if (!webUrl) return null;
|
if (!webUrl) return null;
|
||||||
@@ -174,8 +174,11 @@ export const getMcpSessionCredentials = (mcpSessionId) => {
|
|||||||
* Set credentials by MCP-Session-Id
|
* Set credentials by MCP-Session-Id
|
||||||
*/
|
*/
|
||||||
export const setMcpSessionCredentials = (mcpSessionId, credentials) => {
|
export const setMcpSessionCredentials = (mcpSessionId, credentials) => {
|
||||||
|
// The plaintext MCP PAT is only needed by the active transport. Do not keep
|
||||||
|
// it in the longer-lived reconnection cache.
|
||||||
|
const { mcp_secret: _mcpSecret, ...persistableCredentials } = credentials;
|
||||||
mcpSessionCredentials.set(mcpSessionId, {
|
mcpSessionCredentials.set(mcpSessionId, {
|
||||||
credentials,
|
credentials: persistableCredentials,
|
||||||
lastAccess: Date.now()
|
lastAccess: Date.now()
|
||||||
});
|
});
|
||||||
console.error(`[Credentials] setMcpSessionCredentials(${mcpSessionId.substring(0, 8)}...) - website=${credentials.website} (total: ${mcpSessionCredentials.size})`);
|
console.error(`[Credentials] setMcpSessionCredentials(${mcpSessionId.substring(0, 8)}...) - website=${credentials.website} (total: ${mcpSessionCredentials.size})`);
|
||||||
|
|||||||
@@ -24,3 +24,9 @@ export {
|
|||||||
|
|
||||||
export { fetchProjectInfo, fetchProjectsList } from './localClient.js';
|
export { fetchProjectInfo, fetchProjectsList } from './localClient.js';
|
||||||
|
|
||||||
|
|
||||||
|
export {
|
||||||
|
ensureFreshSessionCredentials,
|
||||||
|
isJwtExpiring,
|
||||||
|
refreshSessionCredentials,
|
||||||
|
} from './sessionRefresh.js';
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../config/index.js";
|
|||||||
* mantiene.
|
* mantiene.
|
||||||
*/
|
*/
|
||||||
export async function fetchProjectInfo(projectName, acaiUser = null, opts = {}) {
|
export async function fetchProjectInfo(projectName, acaiUser = null, opts = {}) {
|
||||||
const params = typeof projectName === "string" ? { project: projectName } : (projectName || {});
|
const params = typeof projectName === "string" ? { project: projectName } : { ...(projectName || {}) };
|
||||||
const headers = getLocalServerHeaders();
|
const headers = getLocalServerHeaders();
|
||||||
if (acaiUser) headers["X-Acai-User"] = acaiUser;
|
if (acaiUser) headers["X-Acai-User"] = acaiUser;
|
||||||
// forceMode: fuerza el modo efectivo con el que el server Python resuelve el
|
// forceMode: fuerza el modo efectivo con el que el server Python resuelve el
|
||||||
@@ -20,6 +20,8 @@ export async function fetchProjectInfo(projectName, acaiUser = null, opts = {})
|
|||||||
// Code) para fijar "local" → la sesion entera apunta al web forge-local
|
// Code) para fijar "local" → la sesion entera apunta al web forge-local
|
||||||
// (test), nunca a produccion, sea cual sea el mode del .acai.
|
// (test), nunca a produccion, sea cual sea el mode del .acai.
|
||||||
if (opts.forceMode) headers["X-Acai-Mode"] = opts.forceMode;
|
if (opts.forceMode) headers["X-Acai-Mode"] = opts.forceMode;
|
||||||
|
if (opts.forceTokenRefresh) params.force_token_refresh = "1";
|
||||||
|
if (opts.mcpSecret) headers["X-MCP-Secret"] = opts.mcpSecret;
|
||||||
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,
|
headers,
|
||||||
|
|||||||
45
mcp-server/auth/mcpAuthMiddleware.js
Normal file
45
mcp-server/auth/mcpAuthMiddleware.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { validateMcpToken } from "./mcpTokens.js";
|
||||||
|
|
||||||
|
const sendJson = (res, status, payload) => {
|
||||||
|
res.status(status)
|
||||||
|
.setHeader("Content-Type", "application/json")
|
||||||
|
.end(JSON.stringify(payload));
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createMcpAuthMiddleware({ validateToken = validateMcpToken } = {}) {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
if (req.path !== "/mcp" && !req.path?.startsWith("/mcp/")) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-provided identity is never trusted on the public MCP transport.
|
||||||
|
delete req.headers["x-acai-user"];
|
||||||
|
|
||||||
|
const secret = req.headers["x-mcp-secret"];
|
||||||
|
if (!secret) {
|
||||||
|
return sendJson(res, 401, { error: "X-MCP-Secret header required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const auth = await validateToken(secret);
|
||||||
|
if (!auth?.user) {
|
||||||
|
return sendJson(res, 401, { error: "Invalid MCP token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.headers["x-acai-user"] = auth.user;
|
||||||
|
if (auth.project) {
|
||||||
|
req.headers["x-project-name"] = auth.project;
|
||||||
|
} else if (!req.headers["x-project-name"]) {
|
||||||
|
return sendJson(res, 400, {
|
||||||
|
error: "X-Project-Name header required for user-wide token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.mcpAuth = auth;
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[MCP auth] token validation failed:", error.message);
|
||||||
|
return sendJson(res, 401, { error: "Invalid MCP token" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
90
mcp-server/auth/sessionRefresh.js
Normal file
90
mcp-server/auth/sessionRefresh.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { fetchProjectInfo } from "./localClient.js";
|
||||||
|
import {
|
||||||
|
getSessionCredentials,
|
||||||
|
sessionCredentials,
|
||||||
|
setMcpSessionCredentials,
|
||||||
|
} from "./credentials.js";
|
||||||
|
|
||||||
|
const refreshInFlight = new Map();
|
||||||
|
|
||||||
|
export function isJwtExpiring(token, marginSeconds = 300) {
|
||||||
|
try {
|
||||||
|
const payload = token.split(".")[1];
|
||||||
|
if (!payload) return true;
|
||||||
|
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
||||||
|
return !data.exp || Math.floor(Date.now() / 1000) >= data.exp - marginSeconds;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshSessionCredentials(
|
||||||
|
sessionId,
|
||||||
|
{ force = false, fetcher = fetchProjectInfo } = {},
|
||||||
|
) {
|
||||||
|
const current = await getSessionCredentials(sessionId);
|
||||||
|
if (!current?.project_dir) {
|
||||||
|
throw new Error("Project dir no disponible en esta sesion");
|
||||||
|
}
|
||||||
|
if (force && !current.mcp_secret && !process.env.ACAI_AUTH_HEADER) {
|
||||||
|
throw new Error("No hay autenticacion interna para autorizar la renovacion forzada");
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = current.project_dir;
|
||||||
|
const existing = refreshInFlight.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const operation = (async () => {
|
||||||
|
let info;
|
||||||
|
try {
|
||||||
|
info = await fetcher(
|
||||||
|
{ project_dir: current.project_dir },
|
||||||
|
current.acai_user || null,
|
||||||
|
{
|
||||||
|
forceTokenRefresh: force,
|
||||||
|
mcpSecret: force ? current.mcp_secret : null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(error.response?.data?.error || error.message);
|
||||||
|
}
|
||||||
|
if (!info?.success) {
|
||||||
|
throw new Error(info?.error || "No se pudieron renovar las credenciales Acai");
|
||||||
|
}
|
||||||
|
if (!info.token || isJwtExpiring(info.token, 0)) {
|
||||||
|
throw new Error("El backend no devolvio un token Acai valido");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fresh = {
|
||||||
|
...current,
|
||||||
|
token: info.token,
|
||||||
|
tokenHash: info.tokenHash || "",
|
||||||
|
website: info.domain || current.website,
|
||||||
|
web_url: info.web_url || current.web_url,
|
||||||
|
api_web_url: info.api_web_url || info.web_url || current.api_web_url,
|
||||||
|
forge_host: info.forge_host ?? current.forge_host,
|
||||||
|
project_dir: info.project_dir || current.project_dir,
|
||||||
|
mode: info.mode || current.mode,
|
||||||
|
};
|
||||||
|
sessionCredentials.set(sessionId, fresh);
|
||||||
|
setMcpSessionCredentials(sessionId, fresh);
|
||||||
|
return fresh;
|
||||||
|
})();
|
||||||
|
|
||||||
|
refreshInFlight.set(key, operation);
|
||||||
|
try {
|
||||||
|
return await operation;
|
||||||
|
} finally {
|
||||||
|
if (refreshInFlight.get(key) === operation) {
|
||||||
|
refreshInFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureFreshSessionCredentials(sessionId) {
|
||||||
|
const current = await getSessionCredentials(sessionId);
|
||||||
|
if (!current?.token || !current.project_dir || !isJwtExpiring(current.token)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return refreshSessionCredentials(sessionId);
|
||||||
|
}
|
||||||
@@ -40,6 +40,8 @@ export const LOCAL_SERVER_URL = process.env.LOCAL_SERVER_URL || 'http://localhos
|
|||||||
// Auth headers para llamadas internas al server Python
|
// Auth headers para llamadas internas al server Python
|
||||||
export function getLocalServerHeaders() {
|
export function getLocalServerHeaders() {
|
||||||
const headers = { "Content-Type": "application/json" };
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
const authHeader = process.env.ACAI_AUTH_HEADER || "";
|
||||||
|
if (authHeader) headers["Authorization"] = authHeader;
|
||||||
// En Forge, usar X-Acai-Token para auth interna
|
// En Forge, usar X-Acai-Token para auth interna
|
||||||
const token = process.env.ACAI_TOKEN || "";
|
const token = process.env.ACAI_TOKEN || "";
|
||||||
const website = process.env.ACAI_WEBSITE || "";
|
const website = process.env.ACAI_WEBSITE || "";
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
getMcpSessionCredentials
|
getMcpSessionCredentials
|
||||||
} from "./auth/index.js";
|
} from "./auth/index.js";
|
||||||
import { fetchProjectInfo } from "./auth/localClient.js";
|
import { fetchProjectInfo } from "./auth/localClient.js";
|
||||||
import { validateMcpToken } from "./auth/mcpTokens.js";
|
import { createMcpAuthMiddleware } from "./auth/mcpAuthMiddleware.js";
|
||||||
import { createSessionServer } from "./server.js";
|
import { createSessionServer } from "./server.js";
|
||||||
import { runWithSession } from "./utils/sessionContext.js";
|
import { runWithSession } from "./utils/sessionContext.js";
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ const resolveProjectCredentials = async (projectName, acaiUser = null) => {
|
|||||||
/**
|
/**
|
||||||
* 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, acaiUser }) => {
|
const configureSessionCredentials = async (sessionId, { token, tokenHash, website, web_url, userToken, projectName, acaiUser, mcpSecret }) => {
|
||||||
// Priority 1: Resolve via project name from local Python server
|
// Priority 1: Resolve via project name from local Python server
|
||||||
if (projectName) {
|
if (projectName) {
|
||||||
const projectCreds = await resolveProjectCredentials(projectName, acaiUser);
|
const projectCreds = await resolveProjectCredentials(projectName, acaiUser);
|
||||||
@@ -126,6 +126,7 @@ const configureSessionCredentials = async (sessionId, { token, tokenHash, websit
|
|||||||
mode: projectCreds.mode || "local",
|
mode: projectCreds.mode || "local",
|
||||||
project_dir: projectCreds.project_dir || null,
|
project_dir: projectCreds.project_dir || null,
|
||||||
acai_user: acaiUser || projectCreds.acai_user || null,
|
acai_user: acaiUser || projectCreds.acai_user || null,
|
||||||
|
mcp_secret: mcpSecret || null,
|
||||||
profileName: 'project-' + projectName,
|
profileName: 'project-' + projectName,
|
||||||
role: 'developer',
|
role: 'developer',
|
||||||
});
|
});
|
||||||
@@ -169,6 +170,7 @@ const extractCredentialsFromRequest = (req) => {
|
|||||||
// Header inyectado por nginx tras validar el secret contra su mapa.
|
// Header inyectado por nginx tras validar el secret contra su mapa.
|
||||||
// Se usa para aislar la resolucion del proyecto a /opt/acai/webs/<user>/.
|
// Se usa para aislar la resolucion del proyecto a /opt/acai/webs/<user>/.
|
||||||
acaiUser: req.headers['x-acai-user'] || null,
|
acaiUser: req.headers['x-acai-user'] || null,
|
||||||
|
mcpSecret: req.headers['x-mcp-secret'] || null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -191,58 +193,9 @@ export function startHttpServer() {
|
|||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
|
|
||||||
//=============================================================================
|
// Streamable HTTP always authenticates with a Redis-backed X-MCP-Secret.
|
||||||
// MCP SECRET MIDDLEWARE
|
// Legacy SSE remains on its existing auth path for now.
|
||||||
// Si llega X-MCP-Secret, lo validamos contra Redis (mcp_tokens:<sha256>) y
|
app.use(createMcpAuthMiddleware());
|
||||||
// reemplazamos los headers de identidad con los del token. El cliente NO
|
|
||||||
// puede forzar X-Acai-User / X-Project-Name si esta usando X-MCP-Secret.
|
|
||||||
// Si NO llega X-MCP-Secret, pasa de largo (modo legacy/dev: el cliente se
|
|
||||||
// identifica manualmente con X-Acai-User + X-Project-Name).
|
|
||||||
//=============================================================================
|
|
||||||
app.use(async (req, res, next) => {
|
|
||||||
// DEBUG temporal: loguear TODA request que llegue. Quitar cuando este
|
|
||||||
// claro el flujo del cliente.
|
|
||||||
const secretPresent = !!req.headers["x-mcp-secret"];
|
|
||||||
const authPresent = !!req.headers["authorization"];
|
|
||||||
console.error(
|
|
||||||
`[MCP req] ${req.method} ${req.url} - X-MCP-Secret=${secretPresent ? "yes" : "MISSING"}, Authorization=${authPresent ? "yes" : "MISSING"}, UA=${(req.headers["user-agent"] || "").substring(0, 60)}`,
|
|
||||||
);
|
|
||||||
const secret = req.headers["x-mcp-secret"];
|
|
||||||
if (!secret) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const auth = await validateMcpToken(secret);
|
|
||||||
if (!auth) {
|
|
||||||
console.error("[MCP middleware] Invalid X-MCP-Secret rejected");
|
|
||||||
res.status(401)
|
|
||||||
.setHeader("Content-Type", "application/json")
|
|
||||||
.end(JSON.stringify({ error: "Invalid MCP token" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Sobrescribe los headers de identidad con los del token validado.
|
|
||||||
req.headers["x-acai-user"] = auth.user;
|
|
||||||
// `auth.project` solo se sobrescribe si el token es project-scoped.
|
|
||||||
// Si es user-wide (auth.project === ""), preservamos el
|
|
||||||
// `X-Project-Name` que el cliente envio (la extension VS Code
|
|
||||||
// Acai Forge lo manda con el slug del proyecto descargado).
|
|
||||||
if (auth.project) {
|
|
||||||
req.headers["x-project-name"] = auth.project;
|
|
||||||
}
|
|
||||||
console.error(
|
|
||||||
`[MCP middleware] Auth OK user=${auth.user} ` +
|
|
||||||
`tokenScope=${auth.project || "user-wide"} ` +
|
|
||||||
`clientProject=${req.headers["x-project-name"] || "(none)"}`,
|
|
||||||
);
|
|
||||||
return next();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[MCP] mcpSecretMiddleware error:", err.message);
|
|
||||||
res.status(401)
|
|
||||||
.setHeader("Content-Type", "application/json")
|
|
||||||
.end(JSON.stringify({ error: "Invalid MCP token" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// STREAMABLE HTTP TRANSPORT (PROTOCOL VERSION 2025-03-26)
|
// STREAMABLE HTTP TRANSPORT (PROTOCOL VERSION 2025-03-26)
|
||||||
@@ -328,6 +281,7 @@ export function startHttpServer() {
|
|||||||
mode: resolvedCreds.mode || "local",
|
mode: resolvedCreds.mode || "local",
|
||||||
project_dir: resolvedCreds.project_dir || null,
|
project_dir: resolvedCreds.project_dir || null,
|
||||||
acai_user: credentials.acaiUser || resolvedCreds.acai_user || null,
|
acai_user: credentials.acaiUser || resolvedCreds.acai_user || null,
|
||||||
|
mcp_secret: credentials.mcpSecret || null,
|
||||||
profileName: 'project-' + credentials.projectName,
|
profileName: 'project-' + credentials.projectName,
|
||||||
role: 'developer',
|
role: 'developer',
|
||||||
};
|
};
|
||||||
@@ -439,11 +393,18 @@ export function startHttpServer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// DEPRECATED HTTP+SSE TRANSPORT (PROTOCOL VERSION 2024-11-05)
|
// DISABLED LEGACY HTTP+SSE TRANSPORT (PROTOCOL VERSION 2024-11-05)
|
||||||
// Kept for backwards compatibility with older clients
|
// Retained below only as dead source-compatible code
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
|
|
||||||
// SSE connection endpoint (GET /sse)
|
// Legacy SSE transport is intentionally disabled. The supported public
|
||||||
|
// transport is Streamable HTTP at /mcp; the frontend chat uses /api/events.
|
||||||
|
app.all(['/sse', '/message'], (req, res) => {
|
||||||
|
res.status(404).json({ error: "Legacy SSE transport disabled; use /mcp" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// SSE implementation retained below only as source-compatible dead code;
|
||||||
|
// the route above terminates all public SSE/message requests first.
|
||||||
app.get('/sse', async (req, res) => {
|
app.get('/sse', async (req, res) => {
|
||||||
console.log(`[MCP SSE] New SSE connection`);
|
console.log(`[MCP SSE] New SSE connection`);
|
||||||
|
|
||||||
@@ -570,8 +531,7 @@ export function startHttpServer() {
|
|||||||
|
|
||||||
// Root path normalization (for clients that call "/" instead of /sse or /mcp)
|
// Root path normalization (for clients that call "/" instead of /sse or /mcp)
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
// Redirect to SSE for backwards compatibility
|
res.status(404).json({ error: "MCP endpoint is /mcp" });
|
||||||
res.redirect('/sse');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/', async (req, res) => {
|
app.post('/', async (req, res) => {
|
||||||
@@ -924,7 +884,7 @@ export function startHttpServer() {
|
|||||||
const server = app.listen(MCP_PORT, '0.0.0.0', () => {
|
const server = app.listen(MCP_PORT, '0.0.0.0', () => {
|
||||||
console.error(`[MCP] Server listening on http://0.0.0.0:${MCP_PORT}`);
|
console.error(`[MCP] Server listening on http://0.0.0.0:${MCP_PORT}`);
|
||||||
console.error(`[MCP] Streamable HTTP endpoint: /mcp (recommended)`);
|
console.error(`[MCP] Streamable HTTP endpoint: /mcp (recommended)`);
|
||||||
console.error(`[MCP] Legacy SSE endpoint: /sse (backwards compatible)`);
|
console.error(`[MCP] Legacy SSE endpoint disabled`);
|
||||||
console.error(`[MCP] Provide credentials via headers: X-Acai-Token, X-Acai-Website, X-Acai-Token-Hash`);
|
console.error(`[MCP] Provide credentials via headers: X-Acai-Token, X-Acai-Website, X-Acai-Token-Hash`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node cluster.js",
|
"start": "node cluster.js",
|
||||||
"start:single": "node index.js",
|
"start:single": "node index.js",
|
||||||
"dev": "nodemon index.js"
|
"dev": "nodemon index.js",
|
||||||
|
"test": "node --test test/*.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||||
|
|||||||
65
mcp-server/test/mcpAuthMiddleware.test.js
Normal file
65
mcp-server/test/mcpAuthMiddleware.test.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { createMcpAuthMiddleware } from "../auth/mcpAuthMiddleware.js";
|
||||||
|
|
||||||
|
async function invoke(middleware, headers = {}, path = "/mcp") {
|
||||||
|
const req = { headers: { ...headers }, path };
|
||||||
|
const response = {};
|
||||||
|
const res = {
|
||||||
|
status(code) { response.status = code; return this; },
|
||||||
|
setHeader() { return this; },
|
||||||
|
end(body) { response.body = JSON.parse(body); return this; },
|
||||||
|
};
|
||||||
|
let nextCalled = false;
|
||||||
|
await middleware(req, res, () => { nextCalled = true; });
|
||||||
|
return { req, response, nextCalled };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("rejects missing and invalid MCP secrets", async () => {
|
||||||
|
const middleware = createMcpAuthMiddleware({ validateToken: async () => null });
|
||||||
|
assert.equal((await invoke(middleware)).response.status, 401);
|
||||||
|
assert.equal((await invoke(middleware, { "x-mcp-secret": "bad" })).response.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("replaces spoofed identity and enforces project scope", async () => {
|
||||||
|
const middleware = createMcpAuthMiddleware({
|
||||||
|
validateToken: async () => ({ user: "owner", project: "allowed" }),
|
||||||
|
});
|
||||||
|
const result = await invoke(middleware, {
|
||||||
|
"x-mcp-secret": "valid",
|
||||||
|
"x-acai-user": "attacker",
|
||||||
|
"x-project-name": "other",
|
||||||
|
});
|
||||||
|
assert.equal(result.nextCalled, true);
|
||||||
|
assert.equal(result.req.headers["x-acai-user"], "owner");
|
||||||
|
assert.equal(result.req.headers["x-project-name"], "allowed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires project context for user-wide tokens", async () => {
|
||||||
|
const middleware = createMcpAuthMiddleware({
|
||||||
|
validateToken: async () => ({ user: "owner", project: "" }),
|
||||||
|
});
|
||||||
|
assert.equal((await invoke(middleware, { "x-mcp-secret": "valid" })).response.status, 400);
|
||||||
|
const accepted = await invoke(middleware, {
|
||||||
|
"x-mcp-secret": "valid",
|
||||||
|
"x-project-name": "demo",
|
||||||
|
});
|
||||||
|
assert.equal(accepted.nextCalled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("revocation is effective on the next request in an existing session", async () => {
|
||||||
|
let revoked = false;
|
||||||
|
const middleware = createMcpAuthMiddleware({
|
||||||
|
validateToken: async () => revoked ? null : ({ user: "owner", project: "demo" }),
|
||||||
|
});
|
||||||
|
const headers = { "x-mcp-secret": "valid", "x-project-name": "demo" };
|
||||||
|
assert.equal((await invoke(middleware, headers)).nextCalled, true);
|
||||||
|
revoked = true;
|
||||||
|
assert.equal((await invoke(middleware, headers)).response.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not apply MCP auth to legacy SSE paths", async () => {
|
||||||
|
const middleware = createMcpAuthMiddleware({ validateToken: async () => null });
|
||||||
|
assert.equal((await invoke(middleware, { "x-acai-user": "legacy" }, "/sse")).nextCalled, true);
|
||||||
|
});
|
||||||
141
mcp-server/test/sessionRefresh.test.js
Normal file
141
mcp-server/test/sessionRefresh.test.js
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { rebuildApiClient, runWithTokenRefreshRetry } from "../auth/apiClient.js";
|
||||||
|
import { mcpSessionCredentials, sessionApiClients, sessionCredentials } from "../auth/credentials.js";
|
||||||
|
import { refreshSessionCredentials } from "../auth/sessionRefresh.js";
|
||||||
|
|
||||||
|
const jwt = (expiresIn = 3600) => {
|
||||||
|
const payload = Buffer.from(JSON.stringify({
|
||||||
|
exp: Math.floor(Date.now() / 1000) + expiresIn,
|
||||||
|
})).toString("base64url");
|
||||||
|
return `header.${payload}.signature`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const credentials = (token) => ({
|
||||||
|
token,
|
||||||
|
tokenHash: "hash",
|
||||||
|
website: "demo.example",
|
||||||
|
web_url: "https://demo.forge.example",
|
||||||
|
api_web_url: "http://web:80",
|
||||||
|
forge_host: "demo.forge.example",
|
||||||
|
mode: "local",
|
||||||
|
project_dir: "/opt/acai/webs/owner/demo",
|
||||||
|
acai_user: "owner",
|
||||||
|
mcp_secret: "acai_secret",
|
||||||
|
role: "developer",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deduplicates concurrent forced refreshes and updates session credentials", async () => {
|
||||||
|
const sessionId = "refresh-session";
|
||||||
|
sessionCredentials.set(sessionId, credentials(jwt()));
|
||||||
|
let calls = 0;
|
||||||
|
const freshToken = jwt(7200);
|
||||||
|
const fetcher = async () => {
|
||||||
|
calls += 1;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
token: freshToken,
|
||||||
|
tokenHash: "fresh-hash",
|
||||||
|
domain: "demo.example",
|
||||||
|
web_url: "https://demo.forge.example",
|
||||||
|
api_web_url: "http://web:80",
|
||||||
|
project_dir: "/opt/acai/webs/owner/demo",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
refreshSessionCredentials(sessionId, { force: true, fetcher }),
|
||||||
|
refreshSessionCredentials(sessionId, { force: true, fetcher }),
|
||||||
|
]);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.equal(first.token, freshToken);
|
||||||
|
assert.equal(second.token, freshToken);
|
||||||
|
assert.equal(sessionCredentials.get(sessionId).tokenHash, "fresh-hash");
|
||||||
|
assert.equal(mcpSessionCredentials.get(sessionId).credentials.mcp_secret, undefined);
|
||||||
|
mcpSessionCredentials.delete(sessionId);
|
||||||
|
sessionCredentials.delete(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cached Axios client reads the latest token for every request", async () => {
|
||||||
|
const sessionId = "axios-session";
|
||||||
|
sessionCredentials.set(sessionId, credentials("old-token"));
|
||||||
|
const client = await rebuildApiClient(sessionId);
|
||||||
|
const observed = [];
|
||||||
|
client.defaults.adapter = async (config) => {
|
||||||
|
observed.push(config.headers["X-Acai-Token"]);
|
||||||
|
return { data: {}, status: 200, statusText: "OK", headers: {}, config };
|
||||||
|
};
|
||||||
|
|
||||||
|
await client.get("/first");
|
||||||
|
sessionCredentials.set(sessionId, credentials("new-token"));
|
||||||
|
await client.get("/second");
|
||||||
|
|
||||||
|
assert.deepEqual(observed, ["old-token", "new-token"]);
|
||||||
|
sessionApiClients.delete(sessionId);
|
||||||
|
sessionCredentials.delete(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("403 token failure forces one refresh and retries the original request once", async () => {
|
||||||
|
const sessionId = "retry-session";
|
||||||
|
sessionCredentials.set(sessionId, credentials("expired-token"));
|
||||||
|
let refreshes = 0;
|
||||||
|
let attempts = 0;
|
||||||
|
const observed = [];
|
||||||
|
const fresh = credentials("fresh-token");
|
||||||
|
const client = await rebuildApiClient(sessionId, {
|
||||||
|
refreshCredentials: async (_sessionId, options) => {
|
||||||
|
refreshes += 1;
|
||||||
|
assert.equal(options.force, true);
|
||||||
|
sessionCredentials.set(sessionId, fresh);
|
||||||
|
return fresh;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
client.defaults.adapter = async (config) => {
|
||||||
|
attempts += 1;
|
||||||
|
observed.push(config.headers["X-Acai-Token"]);
|
||||||
|
if (attempts === 1) {
|
||||||
|
const error = new Error("Request failed with status code 403");
|
||||||
|
error.config = config;
|
||||||
|
error.response = { status: 403, data: { error: "Token no valido" } };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return { data: { success: true }, status: 200, statusText: "OK", headers: {}, config };
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await client.get("/retry-once");
|
||||||
|
|
||||||
|
assert.equal(response.data.success, true);
|
||||||
|
assert.equal(refreshes, 1);
|
||||||
|
assert.equal(attempts, 2);
|
||||||
|
assert.deepEqual(observed, ["expired-token", "fresh-token"]);
|
||||||
|
sessionApiClients.delete(sessionId);
|
||||||
|
sessionCredentials.delete(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("tool-level retry covers Axios clients outside the cached API client", async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
let refreshes = 0;
|
||||||
|
const operation = async () => {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts === 1) {
|
||||||
|
const error = new Error("forbidden");
|
||||||
|
error.response = { status: 403, data: { error: "JWT expired" } };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return "ok";
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await runWithTokenRefreshRetry("tool-session", operation, {
|
||||||
|
refreshCredentials: async (_sessionId, options) => {
|
||||||
|
refreshes += 1;
|
||||||
|
assert.equal(options.force, true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, "ok");
|
||||||
|
assert.equal(attempts, 2);
|
||||||
|
assert.equal(refreshes, 1);
|
||||||
|
});
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { sessionCredentials } from "../../auth/credentials.js";
|
||||||
import { sessionCredentials, setMcpSessionCredentials } from "../../auth/credentials.js";
|
import { refreshSessionCredentials } from "../../auth/sessionRefresh.js";
|
||||||
import { withAuthParams } from "../helpers/authSchema.js";
|
import { withAuthParams } from "../helpers/authSchema.js";
|
||||||
import { fetchProjectInfo } from "../../auth/localClient.js";
|
|
||||||
import { resolveCurrentProjectDir } from "../files/helpers.js";
|
import { resolveCurrentProjectDir } from "../files/helpers.js";
|
||||||
import { resolveCurrentAcaiUser } from "../helpers/sessionHelpers.js";
|
|
||||||
import { getCurrentSessionId } from "../../utils/sessionContext.js";
|
import { getCurrentSessionId } from "../../utils/sessionContext.js";
|
||||||
|
|
||||||
export function registerAuthTools(server) {
|
export function registerAuthTools(server) {
|
||||||
@@ -22,60 +20,10 @@ export function registerAuthTools(server) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const acaiUser = resolveCurrentAcaiUser();
|
const mcpSessionId = getCurrentSessionId() || extra?.sessionId || "_default";
|
||||||
|
const previousToken = sessionCredentials.get(mcpSessionId)?.token || null;
|
||||||
// Delegamos al Python que ya gestiona expiracion + refresh + persistencia
|
const freshCreds = await refreshSessionCredentials(mcpSessionId, { force: true });
|
||||||
let info;
|
const rotated = Boolean(previousToken && previousToken !== freshCreds.token);
|
||||||
try {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const freshCreds = {
|
|
||||||
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",
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
|
|||||||
Reference in New Issue
Block a user