Files
Momento/mcp-server/index-sse.js
Antigravity 0784c94242
Some checks failed
CI / Lint, Test & Build (push) Failing after 57s
CI / Deploy production (on server) (push) Has been skipped
feat(notes): vues structurées tableau/kanban, flashcards et MCP robuste
Ajoute la base organisable par carnet (schéma, champs partagés, valeurs par note)
avec activation guidée, tableau éditable, kanban et suppression de colonnes.
Corrige le multiselect en vue tableau et enrichit sidebar, grille et i18n FR/EN.
Inclut aussi les améliorations flashcards SM-2, l'audit consentement IA et la
robustesse du serveur MCP (config, validation, rate-limit, métriques).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 23:03:16 +00:00

588 lines
22 KiB
JavaScript

#!/usr/bin/env node
/**
* Memento MCP Server - Streamable HTTP Transport (Enhanced)
*
* Features:
* - Prisma connection pooling
* - Compact JSON output
* - Bounded session cache
* - Proper keep-alive & timeouts
* - O(1) API key validation
* - Structured error handling
* - Observability metrics
* - Rate limiting
* - Input validation
* - Audit logging
*
* Environment:
* PORT Server port (default: 3001)
* DATABASE_URL Prisma database URL
* USER_ID Optional user ID filter
* APP_BASE_URL Next.js app URL (default: http://localhost:3000)
* MCP_REQUIRE_AUTH Set 'true' to require authentication
* MCP_API_KEY Static fallback API key
* MCP_LOG_LEVEL debug, info, warn, error (default: info)
* MCP_REQUEST_TIMEOUT Timeout in ms (default: 30000)
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { PrismaClient } from '@prisma/client';
import { randomBytes } from 'crypto';
import express from 'express';
import cors from 'cors';
import { registerTools } from './tools.js';
import { validateApiKey, resolveUser } from './auth.js';
import { requestContext } from './request-context.js';
import config, { validateConfig, printConfig } from './config.js';
import {
mcpError,
mcpErrorContent,
McpErrors,
getErrorCategory,
withErrorHandling,
logError,
} from './errors.js';
import {
recordRequest,
recordError,
recordAuth,
recordDbQuery,
recordSession,
getPrometheusMetrics,
getMetricsSummary,
updateCacheSize,
} from './metrics.js';
import { combinedRateLimitMiddleware, getRateLimitStats } from './rate-limit.js';
import { validateAndSanitize, checkXSS } from './validation.js';
// ═══════════════════════════════════════════════════════════════
// Configuration Validation
// ═══════════════════════════════════════════════════════════════
const configErrors = validateConfig();
if (configErrors.some((e) => e.critical)) {
console.error('❌ CRITICAL CONFIGURATION ERRORS:');
configErrors.forEach((e) => console.error(` ${e.key}: ${e.message}`));
process.exit(1);
}
if (configErrors.length > 0) {
console.warn('⚠️ Configuration warnings:');
configErrors.forEach((e) => console.warn(` ${e.key}: ${e.message}`));
}
// ═══════════════════════════════════════════════════════════════
// Logging
// ═══════════════════════════════════════════════════════════════
const logLevels = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
const currentLogLevel = logLevels[config.logLevel] ?? 1;
function log(level, ...args) {
if (logLevels[level] >= currentLogLevel) {
const timestamp = new Date().toISOString();
console.error(`[${timestamp}] [${level.toUpperCase()}]`, ...args);
}
}
// ═══════════════════════════════════════════════════════════════
// Database Setup
// ═══════════════════════════════════════════════════════════════
const databaseUrl = config.databaseUrl;
if (!databaseUrl) {
console.error('ERROR: DATABASE_URL is required');
process.exit(1);
}
const isPostgres = databaseUrl.startsWith('postgresql://') || databaseUrl.startsWith('postgres://');
const prisma = new PrismaClient({
datasources: { db: { url: databaseUrl } },
...(isPostgres
? {
datasources: {
db: {
url: `${databaseUrl}${databaseUrl.includes('?') ? '&' : '?'}connection_limit=${config.connectionLimit}&pool_timeout=${config.poolTimeout}`,
},
},
}
: {}),
log: config.logLevel === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
});
// Wrap Prisma for metrics
const originalQuery = prisma.$queryRaw.bind(prisma);
prisma.$queryRaw = async (...args) => {
const start = Date.now();
try {
const result = await originalQuery(...args);
recordDbQuery(true, Date.now() - start);
return result;
} catch (error) {
recordDbQuery(false, Date.now() - start);
throw error;
}
};
const appBaseUrl = config.appBaseUrl;
// ═══════════════════════════════════════════════════════════════
// Bounded Session Cache
// ═══════════════════════════════════════════════════════════════
const sessions = new Map();
function cleanupSessions() {
const now = Date.now();
let cleaned = 0;
for (const [key, s] of sessions) {
if (now - s._lastSeen > config.sessionTtl) {
sessions.delete(key);
cleaned++;
}
}
if (cleaned > 0) {
log('debug', `Cleaned ${cleaned} expired sessions`);
recordSession('expire', cleaned);
}
updateCacheSize(sessions.size);
}
function pruneIfFull() {
if (sessions.size < config.maxSessions) return;
const entries = [...sessions.entries()].sort((a, b) => a[1]._lastSeen - b[1]._lastSeen);
for (let i = 0; i < Math.floor(config.maxSessions / 4); i++) {
sessions.delete(entries[i][0]);
}
}
setInterval(cleanupSessions, config.sessionCleanupInterval);
// ═══════════════════════════════════════════════════════════════
// Express App Setup
// ═══════════════════════════════════════════════════════════════
const app = express();
// CORS configuration
if (config.allowedOrigins.length > 0 && !config.allowedOrigins.includes('*')) {
app.use(
cors({
origin: config.allowedOrigins,
credentials: true,
})
);
} else {
app.use(cors());
}
app.use(express.json({ limit: config.maxRequestSize }));
// ═══════════════════════════════════════════════════════════════
// Request Logging Middleware
// ═══════════════════════════════════════════════════════════════
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const ms = Date.now() - start;
const sid = req.userSession?.id?.substring(0, 8) || 'anon';
log('debug', `[${sid}] ${req.method} ${req.path} ${res.statusCode} ${ms}ms`);
recordRequest('http', res.statusCode, req.method, ms);
});
next();
});
// ═══════════════════════════════════════════════════════════════
// Timeout Middleware
// ═══════════════════════════════════════════════════════════════
app.use((req, res, next) => {
req.setTimeout(config.requestTimeout);
res.setTimeout(config.requestTimeout, () => {
if (!res.headersSent) {
recordError(getErrorCategory(McpErrors.TIMEOUT.code), McpErrors.TIMEOUT.code);
res.status(408).json(mcpError(McpErrors.TIMEOUT.code));
}
});
next();
});
// ═══════════════════════════════════════════════════════════════
// Security Middleware (XSS Check)
// ═══════════════════════════════════════════════════════════════
app.use((req, res, next) => {
if (req.body && checkXSS(req.body)) {
recordError('xss', 'xss_detected', { path: req.path });
return res.status(400).json(mcpError(McpErrors.INVALID_PARAMS.code, {
detail: 'Request contains potentially malicious content',
}));
}
next();
});
// ═══════════════════════════════════════════════════════════════
// Rate Limiting Middleware
// ═══════════════════════════════════════════════════════════════
app.use(combinedRateLimitMiddleware);
// ═══════════════════════════════════════════════════════════════
// Health Endpoint (before auth - for Docker healthcheck)
// ═══════════════════════════════════════════════════════════════
app.get(config.healthPath, async (req, res) => {
try {
// Check database connection
await prisma.$queryRaw`SELECT 1`;
res.json({
ok: true,
uptime: process.uptime(),
timestamp: new Date().toISOString(),
metrics: getMetricsSummary(),
rateLimit: getRateLimitStats(),
sessions: {
active: sessions.size,
max: config.maxSessions,
},
});
} catch (error) {
res.status(503).json({
ok: false,
error: 'Database connection failed',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
});
}
});
// ═══════════════════════════════════════════════════════════════
// Metrics Endpoint
// ═══════════════════════════════════════════════════════════════
if (config.enableMetrics) {
app.get(config.metricsPath, (req, res) => {
res.set('Content-Type', 'text/plain');
res.send(getPrometheusMetrics());
});
}
// ═══════════════════════════════════════════════════════════════
// Auth Middleware
// ═══════════════════════════════════════════════════════════════
app.use(
withErrorHandling(async (req, res, next) => {
if (!config.requireAuth) {
req.userSession = { id: 'dev-user', name: 'Development User', isAuth: false };
recordAuth(true, 'dev-mode');
return next();
}
const apiKey = req.headers['x-api-key'];
const headerUserId = req.headers['x-user-id'];
if (!apiKey && !headerUserId) {
recordAuth(false, 'missing-credentials');
return res
.status(401)
.json(
mcpError(McpErrors.AUTH_FAILED.code, {
detail: 'Provide x-api-key or x-user-id header',
})
);
}
if (apiKey) {
const keyUser = await validateApiKey(prisma, apiKey);
if (keyUser) {
req.userSession = getOrCreateSession(
`key:${keyUser.apiKeyId}`,
{
name: `${keyUser.userName} (${keyUser.apiKeyName})`,
userId: keyUser.userId,
userName: keyUser.userName,
apiKeyId: keyUser.apiKeyId,
authMethod: 'api-key',
}
);
recordAuth(true, 'api-key');
return next();
}
if (config.staticApiKey && apiKey === config.staticApiKey) {
req.userSession = getOrCreateSession(`static:${apiKey.substring(0, 8)}`, {
name: 'Static API Key User',
userId: config.userId || null,
authMethod: 'static-key',
});
recordAuth(true, 'static-key');
return next();
}
recordAuth(false, 'invalid-api-key');
return res.status(401).json(mcpError(McpErrors.AUTH_FAILED.code, { detail: 'Invalid API key' }));
}
if (headerUserId) {
const user = await resolveUser(prisma, headerUserId);
if (!user) {
recordAuth(false, 'user-not-found');
return res.status(401).json(mcpError(McpErrors.AUTH_FAILED.code, { detail: 'User not found' }));
}
req.userSession = getOrCreateSession(`user:${user.id}`, {
name: user.name,
userId: user.id,
userName: user.name,
userEmail: user.email,
userRole: user.role,
authMethod: 'user-id',
});
recordAuth(true, 'user-id');
return next();
}
recordAuth(false, 'auth-failed');
return res.status(401).json(mcpError(McpErrors.AUTH_FAILED.code, { detail: 'Authentication failed' }));
})
);
function getOrCreateSession(key, base) {
const existing = sessions.get(key);
if (existing) {
existing._lastSeen = Date.now();
existing.requestCount = (existing.requestCount || 0) + 1;
return existing;
}
pruneIfFull();
const s = {
id: randomBytes(16).toString('hex'),
...base,
connectedAt: new Date().toISOString(),
requestCount: 1,
isAuth: true,
_lastSeen: Date.now(),
};
sessions.set(key, s);
recordSession('create');
return s;
}
// ═══════════════════════════════════════════════════════════════
// MCP Server Setup
// ═══════════════════════════════════════════════════════════════
const server = new Server(
{ name: 'memento-mcp-server', version: '3.2.0' },
{ capabilities: { tools: {} } },
);
registerTools(server, prisma);
// ═══════════════════════════════════════════════════════════════
// Routes
// ═══════════════════════════════════════════════════════════════
app.get('/', (req, res) => {
res.json({
name: 'Memento MCP Server',
version: '3.2.0',
status: 'running',
endpoints: {
mcp: '/mcp',
health: config.healthPath,
metrics: config.enableMetrics ? config.metricsPath : undefined,
sessions: '/sessions',
},
auth: { enabled: config.requireAuth },
tools: 22,
uptime: process.uptime(),
});
});
app.get('/sessions', (req, res) => {
const list = [...sessions.values()].map((s) => ({
id: s.id,
name: s.name,
connectedAt: s.connectedAt,
requestCount: s.requestCount || 0,
authMethod: s.authMethod,
}));
res.json({ activeUsers: list.length, sessions: list, uptime: process.uptime() });
});
// ═══════════════════════════════════════════════════════════════
// MCP Endpoint with Input Validation
// ═══════════════════════════════════════════════════════════════
const transports = {};
app.all(
'/mcp',
withErrorHandling(async (req, res) => {
const sessionId = req.headers['mcp-session-id'];
let transport;
if (sessionId && transports[sessionId]) {
transport = transports[sessionId];
} else {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomBytes(16).toString('hex'),
onsessioninitialized: (id) => {
log('debug', `Session init: ${id}`);
transports[id] = transport;
},
});
transport.onclose = () => {
const sid = transport.sessionId;
if (sid) {
log('debug', `Session close: ${sid}`);
delete transports[sid];
}
};
await server.connect(transport);
}
// Validate tool input if present
if (req.body?.method) {
const toolName = req.body.method;
if (req.body?.params) {
const validation = validateAndSanitize(toolName, req.body.params);
if (!validation.success) {
log('warn', `Validation failed for ${toolName}:`, validation.errors);
return res
.status(400)
.json(
mcpError(McpErrors.INVALID_PARAMS.code, {
detail: 'Input validation failed',
field: validation.errors[0]?.field,
context: { toolName, errors: validation.errors },
})
);
}
// Update request with sanitized data
req.body.params = validation.data;
}
}
const ctx = { userId: req.userSession?.userId || null };
await requestContext.run(ctx, async () => {
await transport.handleRequest(req, res, req.body);
});
})
);
// Legacy /sse → /mcp redirect
app.all('/sse', (req, res) => {
res.redirect(307, '/mcp');
});
// ═══════════════════════════════════════════════════════════════
// Debug Routes (only in development)
// ═══════════════════════════════════════════════════════════════
if (config.nodeEnv === 'development') {
app.get('/debug/config', (req, res) => {
const { getPublicConfig } = require('./config.js');
res.json({ config: getPublicConfig() });
});
app.get('/debug/sessions', (req, res) => {
const sessionList = [...sessions.entries()].map(([key, s]) => ({
key,
id: s.id,
name: s.name,
requestCount: s.requestCount || 0,
_lastSeen: s._lastSeen,
}));
res.json({ sessions: sessionList, total: sessions.size });
});
app.delete('/debug/sessions/:key', (req, res) => {
sessions.delete(req.params.key);
res.json({ ok: true });
});
app.post('/debug/sessions/clear', (req, res) => {
sessions.clear();
res.json({ ok: true });
});
}
// ═══════════════════════════════════════════════════════════════
// Start Server
// ═══════════════════════════════════════════════════════════════
async function main() {
try {
await prisma.$queryRaw`SELECT 1`;
} catch (error) {
console.error('FATAL: Database connection failed:', error.message);
process.exit(1);
}
// Print configuration
printConfig();
app.listen(config.port, '0.0.0.0', () => {
console.log(`
╔═══════════════════════════════════════════════════════╗
║ Memento MCP Server v3.2.0 (Enhanced) ║
║ Streamable HTTP Transport ║
╚═══════════════════════════════════════════════════════╝
Server: http://localhost:${config.port}
MCP: http://localhost:${config.port}/mcp
Health: http://localhost:${config.port}${config.healthPath}
Metrics: http://localhost:${config.port}${config.metricsPath}
Auth: ${config.requireAuth ? 'ENABLED' : 'DISABLED (dev)'}
Timeout: ${config.requestTimeout}ms
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
Tools: 22
Features: ${config.enableMetrics ? 'Metrics' : ''}${config.enableAuditLog ? ', Audit Log' : ''}
`);
});
}
main().catch((error) => {
console.error('Server error:', error);
process.exit(1);
});
// ═══════════════════════════════════════════════════════════════
// Shutdown Handler
// ═══════════════════════════════════════════════════════════════
async function shutdown() {
log('info', 'Shutting down...');
await prisma.$disconnect();
// Close all transports
for (const [id, transport] of Object.entries(transports)) {
try {
transport.close();
} catch (e) {
// Ignore errors during shutdown
}
}
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('uncaughtException', (err) => {
logError(log, err);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
log('error', 'Unhandled rejection:', reason);
recordError(getErrorCategory(McpErrors.INTERNAL_ERROR.code), 'unhandled_rejection');
});