diff --git a/mcp-server/auth/apiClient.js b/mcp-server/auth/apiClient.js index fc426a6..b515993 100644 --- a/mcp-server/auth/apiClient.js +++ b/mcp-server/auth/apiClient.js @@ -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), + ); }; }; diff --git a/mcp-server/auth/credentials.js b/mcp-server/auth/credentials.js index 62ec2c7..2223d8c 100644 --- a/mcp-server/auth/credentials.js +++ b/mcp-server/auth/credentials.js @@ -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})`); diff --git a/mcp-server/auth/index.js b/mcp-server/auth/index.js index 00d4d02..9af5fb0 100644 --- a/mcp-server/auth/index.js +++ b/mcp-server/auth/index.js @@ -24,3 +24,9 @@ export { export { fetchProjectInfo, fetchProjectsList } from './localClient.js'; + +export { + ensureFreshSessionCredentials, + isJwtExpiring, + refreshSessionCredentials, +} from './sessionRefresh.js'; diff --git a/mcp-server/auth/localClient.js b/mcp-server/auth/localClient.js index 6063883..6e42c9d 100644 --- a/mcp-server/auth/localClient.js +++ b/mcp-server/auth/localClient.js @@ -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, diff --git a/mcp-server/auth/mcpAuthMiddleware.js b/mcp-server/auth/mcpAuthMiddleware.js new file mode 100644 index 0000000..a25a810 --- /dev/null +++ b/mcp-server/auth/mcpAuthMiddleware.js @@ -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" }); + } + }; +} diff --git a/mcp-server/auth/sessionRefresh.js b/mcp-server/auth/sessionRefresh.js new file mode 100644 index 0000000..bd9fcae --- /dev/null +++ b/mcp-server/auth/sessionRefresh.js @@ -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); +} diff --git a/mcp-server/config/index.js b/mcp-server/config/index.js index 275b3cf..8a7340d 100644 --- a/mcp-server/config/index.js +++ b/mcp-server/config/index.js @@ -40,6 +40,8 @@ export const LOCAL_SERVER_URL = process.env.LOCAL_SERVER_URL || 'http://localhos // Auth headers para llamadas internas al server Python export function getLocalServerHeaders() { 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 const token = process.env.ACAI_TOKEN || ""; const website = process.env.ACAI_WEBSITE || ""; diff --git a/mcp-server/httpServer.js b/mcp-server/httpServer.js index 8ae6daa..f8dcd5e 100644 --- a/mcp-server/httpServer.js +++ b/mcp-server/httpServer.js @@ -18,7 +18,7 @@ import { getMcpSessionCredentials } from "./auth/index.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 { 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 */ -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 if (projectName) { const projectCreds = await resolveProjectCredentials(projectName, acaiUser); @@ -126,6 +126,7 @@ const configureSessionCredentials = async (sessionId, { token, tokenHash, websit mode: projectCreds.mode || "local", project_dir: projectCreds.project_dir || null, acai_user: acaiUser || projectCreds.acai_user || null, + mcp_secret: mcpSecret || null, profileName: 'project-' + projectName, role: 'developer', }); @@ -169,6 +170,7 @@ const extractCredentialsFromRequest = (req) => { // Header inyectado por nginx tras validar el secret contra su mapa. // Se usa para aislar la resolucion del proyecto a /opt/acai/webs//. acaiUser: req.headers['x-acai-user'] || null, + mcpSecret: req.headers['x-mcp-secret'] || null, }; }; @@ -191,58 +193,9 @@ export function startHttpServer() { credentials: true })); - //============================================================================= - // MCP SECRET MIDDLEWARE - // Si llega X-MCP-Secret, lo validamos contra Redis (mcp_tokens:) y - // 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 always authenticates with a Redis-backed X-MCP-Secret. + // Legacy SSE remains on its existing auth path for now. + app.use(createMcpAuthMiddleware()); //============================================================================= // STREAMABLE HTTP TRANSPORT (PROTOCOL VERSION 2025-03-26) @@ -328,6 +281,7 @@ export function startHttpServer() { mode: resolvedCreds.mode || "local", project_dir: resolvedCreds.project_dir || null, acai_user: credentials.acaiUser || resolvedCreds.acai_user || null, + mcp_secret: credentials.mcpSecret || null, profileName: 'project-' + credentials.projectName, role: 'developer', }; @@ -439,11 +393,18 @@ export function startHttpServer() { }); //============================================================================= - // DEPRECATED HTTP+SSE TRANSPORT (PROTOCOL VERSION 2024-11-05) - // Kept for backwards compatibility with older clients + // DISABLED LEGACY HTTP+SSE TRANSPORT (PROTOCOL VERSION 2024-11-05) + // 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) => { 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) app.get('/', (req, res) => { - // Redirect to SSE for backwards compatibility - res.redirect('/sse'); + res.status(404).json({ error: "MCP endpoint is /mcp" }); }); app.post('/', async (req, res) => { @@ -924,7 +884,7 @@ export function startHttpServer() { 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] 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`); }); diff --git a/mcp-server/package.json b/mcp-server/package.json index a955fe1..5b315e0 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -7,7 +7,8 @@ "scripts": { "start": "node cluster.js", "start:single": "node index.js", - "dev": "nodemon index.js" + "dev": "nodemon index.js", + "test": "node --test test/*.test.js" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/mcp-server/test/mcpAuthMiddleware.test.js b/mcp-server/test/mcpAuthMiddleware.test.js new file mode 100644 index 0000000..385c518 --- /dev/null +++ b/mcp-server/test/mcpAuthMiddleware.test.js @@ -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); +}); diff --git a/mcp-server/test/sessionRefresh.test.js b/mcp-server/test/sessionRefresh.test.js new file mode 100644 index 0000000..fdc6b50 --- /dev/null +++ b/mcp-server/test/sessionRefresh.test.js @@ -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); +}); diff --git a/mcp-server/tools/auth/index.js b/mcp-server/tools/auth/index.js index 31a99b0..7e3a1af 100644 --- a/mcp-server/tools/auth/index.js +++ b/mcp-server/tools/auth/index.js @@ -1,9 +1,7 @@ -import { z } from "zod"; -import { sessionCredentials, setMcpSessionCredentials } from "../../auth/credentials.js"; +import { sessionCredentials } from "../../auth/credentials.js"; +import { refreshSessionCredentials } from "../../auth/sessionRefresh.js"; import { withAuthParams } from "../helpers/authSchema.js"; -import { fetchProjectInfo } from "../../auth/localClient.js"; import { resolveCurrentProjectDir } from "../files/helpers.js"; -import { resolveCurrentAcaiUser } from "../helpers/sessionHelpers.js"; import { getCurrentSessionId } from "../../utils/sessionContext.js"; export function registerAuthTools(server) { @@ -22,60 +20,10 @@ export function registerAuthTools(server) { }; } - const acaiUser = resolveCurrentAcaiUser(); - - // Delegamos al Python que ya gestiona expiracion + refresh + persistencia - let info; - 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; + const mcpSessionId = getCurrentSessionId() || extra?.sessionId || "_default"; + const previousToken = sessionCredentials.get(mcpSessionId)?.token || null; + const freshCreds = await refreshSessionCredentials(mcpSessionId, { force: true }); + const rotated = Boolean(previousToken && previousToken !== freshCreds.token); return { content: [{