Initial commit
This commit is contained in:
788
mcp-server/httpServer.js
Normal file
788
mcp-server/httpServer.js
Normal 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 };
|
||||
Reference in New Issue
Block a user