Initial commit

This commit is contained in:
Jordan
2026-04-01 23:16:45 +01:00
commit bc4199aed2
201 changed files with 25612 additions and 0 deletions

788
mcp-server/httpServer.js Normal file
View File

@@ -0,0 +1,788 @@
import fs from "fs";
import path from "path";
import os from "os";
import crypto from "node:crypto";
import express from "express";
import cors from "cors";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { MCP_PORT, JWT_SECRET } from "./config/index.js";
import {
sessionCredentials,
sessionApiClients,
sessionServers,
clearSessionCredentials,
setSessionUserToken,
setMcpSessionCredentials,
getMcpSessionCredentials
} from "./auth/index.js";
import { fetchProjectInfo } from "./auth/localClient.js";
import { createSessionServer } from "./server.js";
// Active sessions - stores { transport, server, type, heartbeatInterval }
const activeSessions = new Map();
const ACCESS_TOKEN_TTL = 3600; // seconds
const base64url = (value) => Buffer.from(value).toString("base64url");
const previewToken = (value) => {
if (!value) return "<empty>";
if (value.length <= 16) return value;
return `${value.slice(0, 12)}...${value.slice(-8)} (len=${value.length})`;
};
const signJwt = (payload, expiresInSeconds = ACCESS_TOKEN_TTL) => {
const header = { alg: "HS256", typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const enrichedPayload = { iat: now, exp: now + expiresInSeconds, ...payload };
const headerEncoded = base64url(JSON.stringify(header));
const payloadEncoded = base64url(JSON.stringify(enrichedPayload));
const signature = crypto
.createHmac("sha256", JWT_SECRET)
.update(`${headerEncoded}.${payloadEncoded}`)
.digest("base64url");
return `${headerEncoded}.${payloadEncoded}.${signature}`;
};
const verifyJwt = (token) => {
try {
const [headerEncoded, payloadEncoded, signature] = token.split(".");
if (!headerEncoded || !payloadEncoded || !signature) {
return null;
}
const expected = crypto
.createHmac("sha256", JWT_SECRET)
.update(`${headerEncoded}.${payloadEncoded}`)
.digest("base64url");
if (expected !== signature) {
return null;
}
const payload = JSON.parse(Buffer.from(payloadEncoded, "base64url").toString());
if (payload.exp && Math.floor(Date.now() / 1000) > payload.exp) {
return null;
}
return payload;
} catch (error) {
console.error(`[MCP HTTP] JWT verification error: ${error.message}`);
return null;
}
};
const resolveProjectCredentials = async (projectName) => {
try {
const info = await fetchProjectInfo(projectName);
if (!info.success) {
throw new Error(info.error || "Failed to resolve project info");
}
return {
token: info.token,
tokenHash: info.tokenHash || null,
website: info.domain,
web_url: info.web_url,
api_web_url: info.api_web_url || info.web_url,
forge_host: info.forge_host || null,
};
} catch (error) {
throw new Error(`Failed to resolve project '${projectName}': ${error.message}`);
}
};
/**
* Configure credentials from request headers/query params for a session
*/
const configureSessionCredentials = async (sessionId, { token, tokenHash, website, web_url, userToken, projectName }) => {
// 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,
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
}
}
// Priority 2: Direct credentials (legacy header-based)
if (token && website) {
sessionCredentials.set(sessionId, {
token,
tokenHash: tokenHash || null,
website,
web_url: web_url || `https://${website}`,
api_web_url: web_url || `https://${website}`,
forge_host: null,
profileName: 'http-session',
role: 'developer',
});
console.log(`[MCP] Session ${sessionId} authenticated - website: ${website}`);
return true;
} else if (userToken) {
setSessionUserToken(userToken, sessionId);
console.log(`[MCP] Session ${sessionId} with userToken for auto-login`);
return true;
}
return false;
};
/**
* Extract credentials from request (headers or query params)
*/
const extractCredentialsFromRequest = (req) => {
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
return {
projectName: url.searchParams.get('project') || req.headers['x-project-name'],
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']
};
};
/**
* Create and start the MCP HTTP server with both Streamable HTTP and SSE transports
*/
export function startHttpServer() {
const app = express();
// Parse JSON and URL-encoded bodies
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Configure CORS
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'],
exposedHeaders: ['Mcp-Session-Id'],
credentials: true
}));
//=============================================================================
// STREAMABLE HTTP TRANSPORT (PROTOCOL VERSION 2025-03-26)
// This is the new recommended transport for MCP
//=============================================================================
app.all('/mcp', async (req, res) => {
console.log(`[MCP Streamable] ${req.method} /mcp`);
try {
const mcpSessionId = req.headers['mcp-session-id'];
let transport;
if (mcpSessionId && activeSessions.has(mcpSessionId)) {
// Reuse existing transport
const session = activeSessions.get(mcpSessionId);
if (session.type === 'streamable') {
transport = session.transport;
console.log(`[MCP Streamable] Reusing session ${mcpSessionId.substring(0, 8)}...`);
} else {
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: Session exists but uses a different transport protocol'
},
id: null
});
return;
}
} else if (!mcpSessionId && req.method === 'POST' && isInitializeRequest(req.body)) {
// New initialization request - create new transport
console.log(`[MCP Streamable] New initialization request`);
// Extract credentials from request
const credentials = extractCredentialsFromRequest(req);
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (sessionId) => {
console.log(`[MCP Streamable] Session initialized: ${sessionId.substring(0, 8)}...`);
// Store the transport
activeSessions.set(sessionId, {
transport,
server: null, // Will be set after connect
type: 'streamable',
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);
}
}
}).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)}...`);
activeSessions.delete(sessionId);
clearSessionCredentials(sessionId);
}
});
// Set up onclose handler
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && activeSessions.has(sid)) {
console.log(`[MCP Streamable] Transport closed for session ${sid.substring(0, 8)}...`);
activeSessions.delete(sid);
clearSessionCredentials(sid);
}
};
// Create session-specific server and connect
const sessionServer = createSessionServer();
await sessionServer.connect(transport);
// Store session server for role filtering
if (transport.sessionId) {
sessionServers.set(transport.sessionId, sessionServer);
}
// Update session with server reference
if (transport.sessionId) {
const session = activeSessions.get(transport.sessionId);
if (session) {
session.server = sessionServer;
}
}
} else if (mcpSessionId) {
// Session ID provided but session not found - try to recover credentials
const savedCreds = getMcpSessionCredentials(mcpSessionId);
if (savedCreds) {
console.log(`[MCP Streamable] Recovering credentials for session ${mcpSessionId.substring(0, 8)}...`);
// Session might have been lost due to server restart - need to re-initialize
}
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: Session not found. Please reinitialize.'
},
id: null
});
return;
} else {
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided and not an initialization request'
},
id: null
});
return;
}
// Handle the request with the transport
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('[MCP Streamable] Error:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error'
},
id: null
});
}
}
});
//=============================================================================
// DEPRECATED HTTP+SSE TRANSPORT (PROTOCOL VERSION 2024-11-05)
// Kept for backwards compatibility with older clients
//=============================================================================
// SSE connection endpoint (GET /sse)
app.get('/sse', async (req, res) => {
console.log(`[MCP SSE] New SSE connection`);
const credentials = extractCredentialsFromRequest(req);
// Create SSE transport
const transport = new SSEServerTransport("/message", res);
const sessionId = transport.sessionId;
console.log(`[MCP SSE] Session created: ${sessionId}`);
// Create session-specific server
const sessionServer = createSessionServer();
// Store session server for role filtering
sessionServers.set(sessionId, sessionServer);
// Set up heartbeat
const heartbeatInterval = setInterval(() => {
try {
if (res.writableEnded || res.destroyed) {
clearInterval(heartbeatInterval);
return;
}
res.write(': heartbeat\n\n');
} catch (err) {
console.error(`[MCP SSE] Heartbeat error for session ${sessionId}:`, err.message);
clearInterval(heartbeatInterval);
}
}, 30000);
// Configure credentials
await configureSessionCredentials(sessionId, credentials);
// Store session
activeSessions.set(sessionId, {
transport,
server: sessionServer,
type: 'sse',
heartbeatInterval,
startTime: Date.now()
});
// Handle close
res.on('close', () => {
console.log(`[MCP SSE] Connection closed for session ${sessionId}`);
});
transport.onclose = () => {
const session = activeSessions.get(sessionId);
if (!session) return;
activeSessions.delete(sessionId);
if (session.heartbeatInterval) {
clearInterval(session.heartbeatInterval);
}
clearSessionCredentials(sessionId);
console.log(`[MCP SSE] Session ${sessionId} cleaned up`);
};
try {
await sessionServer.connect(transport);
console.log(`[MCP SSE] Session ${sessionId} connected`);
} catch (error) {
console.error(`[MCP SSE] Connection error for session ${sessionId}:`, error.message);
activeSessions.delete(sessionId);
clearSessionCredentials(sessionId);
}
});
// Message endpoint for SSE transport (POST /message)
app.post('/message', async (req, res) => {
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
const sessionId = url.searchParams.get("sessionId");
if (!sessionId) {
res.status(400).json({ error: "Missing session ID" });
return;
}
const session = activeSessions.get(sessionId);
if (!session || session.type !== 'sse') {
res.status(404).json({ error: "Session not found" });
return;
}
const { transport, heartbeatInterval } = session;
// Check if SSE connection is still alive
const sseResponse = transport._sseResponse;
if (!sseResponse || sseResponse.writableEnded || sseResponse.destroyed) {
activeSessions.delete(sessionId);
clearSessionCredentials(sessionId);
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
}
res.status(410).json({ error: "SSE connection closed", code: "SSE_CLOSED" });
return;
}
try {
await transport.handlePostMessage(req, res, req.body);
} catch (error) {
console.error(`[MCP SSE] POST error for session ${sessionId}:`, error.message);
if (!res.headersSent) {
res.status(500).json({ error: error.message });
}
}
});
// 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');
});
app.post('/', async (req, res) => {
// Check if it's a Streamable HTTP initialize request
if (isInitializeRequest(req.body)) {
// Forward to /mcp
req.url = '/mcp';
app.handle(req, res);
} else {
// Forward to /message (needs sessionId in query)
req.url = '/message';
app.handle(req, res);
}
});
//=============================================================================
// HEALTH CHECK
//=============================================================================
app.get('/health', (req, res) => {
const sseCount = Array.from(activeSessions.values()).filter(s => s.type === 'sse').length;
const streamableCount = Array.from(activeSessions.values()).filter(s => s.type === 'streamable').length;
res.json({
status: "ok",
activeSessions: activeSessions.size,
sse: sseCount,
streamable: streamableCount,
mode: "hybrid"
});
});
//=============================================================================
// OAUTH2 ENDPOINTS
//=============================================================================
// OAuth2 Authorization Server Metadata endpoint (per RFC8414)
app.get('/.well-known/oauth-authorization-server', (req, res) => {
const baseUrl = `https://${req.headers.host}`;
res.json({
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/authorize`,
token_endpoint: `${baseUrl}/token`,
registration_endpoint: `${baseUrl}/register`,
grant_types_supported: ["authorization_code", "client_credentials"],
response_types_supported: ["code"],
token_endpoint_auth_methods_supported: ["client_secret_post", "none"],
service_documentation: `${baseUrl}/docs`,
code_challenge_methods_supported: ["S256"],
mcp_endpoint: `${baseUrl}/mcp`
});
});
// OAuth2 Dynamic Client Registration endpoint (per RFC7591)
app.post('/register', (req, res) => {
const clientInfo = req.body;
console.error(`[MCP HTTP] POST /register - Client registration request:`, clientInfo);
const clientId = `acai-${Date.now()}-${Math.random().toString(36).substring(7)}`;
res.status(201).json({
client_id: clientId,
client_id_issued_at: Math.floor(Date.now() / 1000),
redirect_uris: clientInfo.redirect_uris || [],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code"],
response_types: ["code"]
});
console.error(`[MCP HTTP] POST /register - Registered client: ${clientId}`);
});
// OAuth2 Authorization endpoint
app.get('/authorize', (req, res) => {
const { client_id: clientId, redirect_uri: redirectUri, state, code_challenge: codeChallenge, code_challenge_method: codeChallengeMethod = "S256" } = req.query;
console.error(`[MCP HTTP] GET /authorize - client_id: ${clientId}, redirect_uri: ${redirectUri}`);
if (!clientId || !redirectUri) {
res.status(400).json({
error: "invalid_request",
error_description: "Missing client_id or redirect_uri"
});
return;
}
const code = Buffer.from(JSON.stringify({
client_id: clientId,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
timestamp: Date.now(),
expires_at: Date.now() + (10 * 60 * 1000)
})).toString('base64');
const callback = new URL(redirectUri);
callback.searchParams.set("code", code);
if (state) callback.searchParams.set("state", state);
console.error(`[MCP HTTP] GET /authorize - Redirecting to: ${callback.toString()}`);
res.redirect(302, callback.toString());
});
// OAuth2 Token endpoint
app.post('/token', async (req, res) => {
console.error(`[MCP HTTP] POST /token - Received token request`);
try {
let params = req.body;
// Handle form-encoded body
const contentType = req.headers['content-type'] || '';
if (contentType.includes('application/x-www-form-urlencoded') && typeof req.body === 'string') {
const searchParams = new URLSearchParams(req.body);
params = Object.fromEntries(searchParams);
}
const { grant_type, client_secret, code, code_verifier } = params;
console.error(`[MCP HTTP] POST /token - grant_type: ${grant_type}, has_code: ${!!code}, has_client_secret: ${!!client_secret}`);
if (grant_type === 'authorization_code') {
if (!code) {
res.status(400).json({
error: "invalid_request",
error_description: "Missing authorization code"
});
return;
}
try {
const decodedCode = JSON.parse(Buffer.from(code, 'base64').toString());
if (decodedCode.expires_at && Date.now() > decodedCode.expires_at) {
res.status(400).json({
error: "invalid_grant",
error_description: "Authorization code has expired"
});
return;
}
if (decodedCode.code_challenge && code_verifier) {
const digest = crypto.createHash('sha256').update(code_verifier).digest('base64url');
if (digest !== decodedCode.code_challenge) {
res.status(400).json({
error: "invalid_grant",
error_description: "PKCE code verifier mismatch"
});
return;
}
}
// client_secret = project name to resolve via local server
if (!client_secret) {
res.status(400).json({
error: "invalid_request",
error_description: "Missing client_secret (should be project name)"
});
return;
}
const projectCreds = await resolveProjectCredentials(client_secret);
const accessToken = signJwt({
acaiToken: projectCreds.token,
acaiTokenHash: projectCreds.tokenHash,
website: projectCreds.website,
web_url: projectCreds.web_url,
clientId: decodedCode.client_id,
tokenType: "acai-credentials",
});
res.setHeader("Cache-Control", "no-store");
res.setHeader("Pragma", "no-cache");
res.json({
access_token: accessToken,
token_type: "Bearer",
expires_in: ACCESS_TOKEN_TTL
});
} catch (error) {
console.error(`[MCP HTTP] POST /token (auth_code) - ERROR:`, error.message);
res.status(400).json({
error: "invalid_grant",
error_description: "Invalid authorization code"
});
}
return;
}
if (grant_type !== 'client_credentials') {
res.status(400).json({
error: "unsupported_grant_type",
error_description: "Only 'client_credentials' and 'authorization_code' grant types are supported"
});
return;
}
// client_secret = project name to resolve via local server
if (!client_secret) {
res.status(400).json({
error: "invalid_request",
error_description: "Missing client_secret (should be project name)"
});
return;
}
const projectCreds = await resolveProjectCredentials(client_secret);
const accessToken = signJwt({
acaiToken: projectCreds.token,
acaiTokenHash: projectCreds.tokenHash,
website: projectCreds.website,
web_url: projectCreds.web_url,
clientId: params.client_id || "client_credentials",
tokenType: "acai-credentials",
});
res.setHeader("Cache-Control", "no-store");
res.setHeader("Pragma", "no-cache");
res.json({
access_token: accessToken,
token_type: "Bearer",
expires_in: ACCESS_TOKEN_TTL
});
} catch (error) {
console.error(`[MCP HTTP] POST /token - ERROR:`, error.message);
const isAuthError = error.message?.toLowerCase().includes("exchange");
res.status(isAuthError ? 400 : 500).json({
error: isAuthError ? "invalid_grant" : "server_error",
error_description: error.message
});
}
});
//=============================================================================
// STATIC FILE SERVING
//=============================================================================
// Serve Figma images
app.get('/figma-images/:fileName', (req, res) => {
const { fileName } = req.params;
if (!fileName) {
res.status(400).json({ error: "File name required" });
return;
}
try {
const figmaDir = process.env.FIGMA_IMAGES_DIR || '/app/figma_images';
const filePath = path.join(figmaDir, fileName);
const resolvedPath = path.resolve(filePath);
const resolvedFigmaDir = path.resolve(figmaDir);
if (!resolvedPath.startsWith(resolvedFigmaDir)) {
res.status(403).json({ error: "Access denied" });
return;
}
if (!fs.existsSync(filePath)) {
res.status(404).json({ error: "File not found", path: fileName });
return;
}
const ext = path.extname(fileName).toLowerCase();
const contentTypes = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml'
};
const contentType = contentTypes[ext] || 'application/octet-stream';
const fileBuffer = fs.readFileSync(filePath);
res.setHeader("Content-Type", contentType);
res.setHeader("Content-Length", fileBuffer.length);
res.setHeader("Cache-Control", "public, max-age=3600");
res.send(fileBuffer);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Serve generated images
app.get('/generated-images/:fileId', (req, res) => {
const { fileId } = req.params;
if (!fileId) {
res.status(400).json({ error: "File ID required" });
return;
}
try {
const tempDir = process.env.GENERATED_IMAGES_TEMP_DIR || path.join(os.tmpdir(), 'generated-images');
const filename = `generated-${fileId}.jpg`;
const filePath = path.join(tempDir, filename);
const resolvedPath = path.resolve(filePath);
const resolvedTempDir = path.resolve(tempDir);
if (!resolvedPath.startsWith(resolvedTempDir)) {
res.status(403).json({ error: "Access denied" });
return;
}
if (!fs.existsSync(filePath)) {
res.status(404).json({ error: "File not found" });
return;
}
const fileBuffer = fs.readFileSync(filePath);
res.setHeader("Content-Type", "image/jpeg");
res.setHeader("Content-Length", fileBuffer.length);
res.setHeader("Cache-Control", "public, max-age=3600");
res.send(fileBuffer);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
//=============================================================================
// START SERVER
//=============================================================================
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] Provide credentials via headers: X-Acai-Token, X-Acai-Website, X-Acai-Token-Hash`);
});
server.on("error", (error) => {
console.error(`[MCP] Server error:`, error);
});
// Handle shutdown
process.on('SIGINT', async () => {
console.log('[MCP] Shutting down server...');
for (const [sessionId, session] of activeSessions.entries()) {
try {
console.log(`[MCP] Closing session ${sessionId}`);
if (session.transport.close) {
await session.transport.close();
}
if (session.heartbeatInterval) {
clearInterval(session.heartbeatInterval);
}
} catch (error) {
console.error(`[MCP] Error closing session ${sessionId}:`, error);
}
}
activeSessions.clear();
console.log('[MCP] Shutdown complete');
process.exit(0);
});
return server;
}
export { activeSessions };