fix MCP auth token refresh and disable legacy SSE

This commit is contained in:
Jordan Diaz
2026-07-18 10:03:12 +00:00
parent 9c3d9fb999
commit 987e050539
12 changed files with 437 additions and 128 deletions

View File

@@ -2,8 +2,30 @@ import axios from "axios";
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
import { sessionApiClients, getSessionCredentials, setCredentials, findRoleByToken } from "./credentials.js";
import { assertSafeCmsTarget } from "../utils/cmsTargetSafety.js";
import { ensureFreshSessionCredentials, refreshSessionCredentials } from "./sessionRefresh.js";
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
*/
@@ -29,7 +51,7 @@ export const ensureConfigured = async (sessionId) => {
/**
* Rebuild API client for a session
*/
export const rebuildApiClient = async (sessionId) => {
export const rebuildApiClient = async (sessionId, { refreshCredentials = refreshSessionCredentials } = {}) => {
const creds = await getSessionCredentials(sessionId);
if (!creds.token || !creds.web_url || !creds.api_web_url) {
return null;
@@ -44,14 +66,29 @@ export const rebuildApiClient = async (sessionId) => {
},
});
// Request interceptor: always send latest token
client.interceptors.request.use((config) => {
if (creds.token) {
config.headers["X-Acai-Token"] = creds.token;
// Always resolve the latest session token. The client outlives individual
// JWTs, so capturing `creds` here would keep sending a rotated token.
client.interceptors.request.use(async (config) => {
const latest = await getSessionCredentials(sessionId);
if (latest.token) {
config.headers["X-Acai-Token"] = latest.token;
}
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);
return client;
};
@@ -133,10 +170,19 @@ export const withAuth = (handler) => {
}, sessionId);
}
await ensureFreshSessionCredentials(sessionId);
console.error(`[withAuth] Getting API client for session ${sessionId}...`);
await getApiClient(sessionId);
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),
);
};
};

View File

@@ -56,7 +56,7 @@ const cleanupExpiredMcpSessions = () => {
};
// Run cleanup every 5 minutes
setInterval(cleanupExpiredMcpSessions, 5 * 60 * 1000);
setInterval(cleanupExpiredMcpSessions, 5 * 60 * 1000).unref();
const buildApiUrlFromPublicUrl = (webUrl, explicitForgeHost = "") => {
if (!webUrl) return null;
@@ -174,8 +174,11 @@ export const getMcpSessionCredentials = (mcpSessionId) => {
* Set credentials by MCP-Session-Id
*/
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, {
credentials,
credentials: persistableCredentials,
lastAccess: Date.now()
});
console.error(`[Credentials] setMcpSessionCredentials(${mcpSessionId.substring(0, 8)}...) - website=${credentials.website} (total: ${mcpSessionCredentials.size})`);

View File

@@ -24,3 +24,9 @@ export {
export { fetchProjectInfo, fetchProjectsList } from './localClient.js';
export {
ensureFreshSessionCredentials,
isJwtExpiring,
refreshSessionCredentials,
} from './sessionRefresh.js';

View File

@@ -12,7 +12,7 @@ import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../config/index.js";
* mantiene.
*/
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();
if (acaiUser) headers["X-Acai-User"] = acaiUser;
// 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
// (test), nunca a produccion, sea cual sea el mode del .acai.
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`, {
params,
headers,

View 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" });
}
};
}

View 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);
}