feat(notes): vues structurées tableau/kanban, flashcards et MCP robuste
Some checks failed
CI / Lint, Test & Build (push) Failing after 57s
CI / Deploy production (on server) (push) Has been skipped

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>
This commit is contained in:
Antigravity
2026-05-24 23:03:16 +00:00
parent ecd7e57c2e
commit 0784c94242
63 changed files with 10133 additions and 619 deletions

View File

@@ -1,12 +1,18 @@
#!/usr/bin/env node
/**
* Memento MCP Server - Streamable HTTP Transport (Fast)
* 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)
@@ -22,29 +28,69 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { PrismaClient } from '@prisma/client';
import { randomUUID } from 'crypto';
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';
const PORT = process.env.PORT || 3001;
const LOG_LEVEL = process.env.MCP_LOG_LEVEL || 'info';
const REQUEST_TIMEOUT = parseInt(process.env.MCP_REQUEST_TIMEOUT, 10) || 30000;
const MAX_SESSIONS = 500;
const SESSION_TTL = 3600000;
// ═══════════════════════════════════════════════════════════════
// Configuration Validation
// ═══════════════════════════════════════════════════════════════
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
const currentLogLevel = logLevels[LOG_LEVEL] ?? 1;
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) {
console.error(`[${level.toUpperCase()}]`, ...args);
const timestamp = new Date().toISOString();
console.error(`[${timestamp}] [${level.toUpperCase()}]`, ...args);
}
}
const databaseUrl = process.env.DATABASE_URL;
// ═══════════════════════════════════════════════════════════════
// Database Setup
// ═══════════════════════════════════════════════════════════════
const databaseUrl = config.databaseUrl;
if (!databaseUrl) {
console.error('ERROR: DATABASE_URL is required');
process.exit(1);
@@ -54,13 +100,37 @@ const isPostgres = databaseUrl.startsWith('postgresql://') || databaseUrl.starts
const prisma = new PrismaClient({
datasources: { db: { url: databaseUrl } },
...(isPostgres ? { datasources: { db: { url: `${databaseUrl}${databaseUrl.includes('?') ? '&' : '?'}connection_limit=10&pool_timeout=10` } } } : {}),
log: LOG_LEVEL === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
...(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'],
});
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
// 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;
}
};
// ── Bounded Session Cache ───────────────────────────────────────────────────
const appBaseUrl = config.appBaseUrl;
// ═══════════════════════════════════════════════════════════════
// Bounded Session Cache
// ═══════════════════════════════════════════════════════════════
const sessions = new Map();
@@ -68,93 +138,219 @@ function cleanupSessions() {
const now = Date.now();
let cleaned = 0;
for (const [key, s] of sessions) {
if (now - s._lastSeen > SESSION_TTL) {
if (now - s._lastSeen > config.sessionTtl) {
sessions.delete(key);
cleaned++;
}
}
if (cleaned > 0) log('debug', `Cleaned ${cleaned} sessions`);
if (cleaned > 0) {
log('debug', `Cleaned ${cleaned} expired sessions`);
recordSession('expire', cleaned);
}
updateCacheSize(sessions.size);
}
function pruneIfFull() {
if (sessions.size < MAX_SESSIONS) return;
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(MAX_SESSIONS / 4); i++) {
for (let i = 0; i < Math.floor(config.maxSessions / 4); i++) {
sessions.delete(entries[i][0]);
}
}
setInterval(cleanupSessions, 600000);
setInterval(cleanupSessions, config.sessionCleanupInterval);
// ── Express ─────────────────────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════
// Express App Setup
// ═══════════════════════════════════════════════════════════════
const app = express();
app.use(cors());
app.use(express.json({ limit: '10mb' }));
// ── Health (before auth middleware — used by Docker healthcheck) ────────────
// CORS configuration
if (config.allowedOrigins.length > 0 && !config.allowedOrigins.includes('*')) {
app.use(
cors({
origin: config.allowedOrigins,
credentials: true,
})
);
} else {
app.use(cors());
}
app.get('/health', (req, res) => res.json({ ok: true, uptime: process.uptime() }));
app.use(express.json({ limit: config.maxRequestSize }));
// ── Auth Middleware ──────────────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════
// Request Logging Middleware
// ═══════════════════════════════════════════════════════════════
app.use(async (req, res, next) => {
if (process.env.MCP_REQUIRE_AUTH !== 'true') {
req.userSession = { id: 'dev-user', name: 'Development User', isAuth: false };
return next();
}
const apiKey = req.headers['x-api-key'];
const headerUserId = req.headers['x-user-id'];
if (!apiKey && !headerUserId) {
return res.status(401).json({ error: 'Authentication required', message: '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',
});
return next();
}
if (process.env.MCP_API_KEY && apiKey === process.env.MCP_API_KEY) {
req.userSession = getOrCreateSession(`static:${apiKey.substring(0, 8)}`, {
name: 'Static API Key User',
userId: process.env.USER_ID || null,
authMethod: 'static-key',
});
return next();
}
return res.status(401).json({ error: 'Invalid API key' });
}
if (headerUserId) {
const user = await resolveUser(prisma, headerUserId);
if (!user) {
return res.status(401).json({ error: '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',
});
return next();
}
return res.status(401).json({ error: 'Authentication failed' });
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) {
@@ -164,7 +360,7 @@ function getOrCreateSession(key, base) {
}
pruneIfFull();
const s = {
id: randomUUID(),
id: randomBytes(16).toString('hex'),
...base,
connectedAt: new Date().toISOString(),
requestCount: 1,
@@ -172,34 +368,13 @@ function getOrCreateSession(key, base) {
_lastSeen: Date.now(),
};
sessions.set(key, s);
recordSession('create');
return s;
}
// ── Logging ─────────────────────────────────────────────────────────────────
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`);
});
next();
});
// ── Timeout ─────────────────────────────────────────────────────────────────
app.use((req, res, next) => {
req.setTimeout(REQUEST_TIMEOUT);
res.setTimeout(REQUEST_TIMEOUT, () => {
if (!res.headersSent) {
res.status(504).json({ error: 'Gateway Timeout' });
}
});
next();
});
// ── MCP Server ──────────────────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════
// MCP Server Setup
// ═══════════════════════════════════════════════════════════════
const server = new Server(
{ name: 'memento-mcp-server', version: '3.2.0' },
@@ -208,93 +383,205 @@ const server = new Server(
registerTools(server, prisma);
// ── Routes ──────────────────────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════
// Routes
// ═══════════════════════════════════════════════════════════════
app.get('/', (req, res) => {
res.json({
name: 'Memento MCP Server',
version: '3.2.0',
status: 'running',
endpoints: { mcp: '/mcp', health: '/health', sessions: '/sessions' },
auth: { enabled: process.env.MCP_REQUIRE_AUTH === 'true' },
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,
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 — Streamable HTTP
app.all('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'];
let transport;
// ═══════════════════════════════════════════════════════════════
// MCP Endpoint with Input Validation
// ═══════════════════════════════════════════════════════════════
if (sessionId && transports[sessionId]) {
transport = transports[sessionId];
} else {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => {
log('debug', `Session init: ${id}`);
transports[id] = transport;
},
});
const transports = {};
transport.onclose = () => {
const sid = transport.sessionId;
if (sid) {
log('debug', `Session close: ${sid}`);
delete transports[sid];
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;
}
};
}
await server.connect(transport);
}
const ctx = { userId: req.userSession?.userId || null };
await requestContext.run(ctx, async () => {
await transport.handleRequest(req, res, req.body);
});
});
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');
});
const transports = {};
// ═══════════════════════════════════════════════════════════════
// Debug Routes (only in development)
// ═══════════════════════════════════════════════════════════════
// ── Start ────────────────────────────────────────────────────────────────────
if (config.nodeEnv === 'development') {
app.get('/debug/config', (req, res) => {
const { getPublicConfig } = require('./config.js');
res.json({ config: getPublicConfig() });
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`
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 (Streamable HTTP)
║ Memento MCP Server v3.2.0 (Enhanced)
║ Streamable HTTP Transport ║
╚═══════════════════════════════════════════════════════╝
Server: http://localhost:${PORT}
MCP: http://localhost:${PORT}/mcp
Auth: ${process.env.MCP_REQUIRE_AUTH === 'true' ? 'ENABLED' : 'DISABLED (dev)'}
Timeout: ${REQUEST_TIMEOUT}ms
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 ─────────────────────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════
// 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) => log('error', 'Uncaught:', err.message));
process.on('unhandledRejection', (reason) => log('error', 'Unhandled rejection:', reason));
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');
});