#!/usr/bin/env node /** * Memento MCP Server - Streamable HTTP Transport (Optimized) * * Performance improvements: * - Prisma connection pooling * - Request timeout handling * - Response compression * - Connection keep-alive * - Request batching support * * Environment variables: * PORT - Server port (default: 3001) * DATABASE_URL - Prisma database URL (default: ../../keep-notes/prisma/dev.db) * USER_ID - Optional user ID to filter data * APP_BASE_URL - Optional Next.js app URL for AI features (default: http://localhost:3000) * MCP_REQUIRE_AUTH - Set to 'true' to require x-api-key or x-user-id header * MCP_API_KEY - Static API key for authentication (when MCP_REQUIRE_AUTH=true) * MCP_LOG_LEVEL - Log level: debug, info, warn, error (default: info) * MCP_REQUEST_TIMEOUT - Request timeout in ms (default: 30000) */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { PrismaClient } from '../keep-notes/prisma/client-generated/index.js'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { randomUUID } from 'crypto'; import express from 'express'; import cors from 'cors'; import { registerTools } from './tools.js'; import { validateApiKey, resolveUser } from './auth.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Configuration 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 logLevels = { debug: 0, info: 1, warn: 2, error: 3 }; const currentLogLevel = logLevels[LOG_LEVEL] ?? 1; function log(level, ...args) { if (logLevels[level] >= currentLogLevel) { console.error(`[${level.toUpperCase()}]`, ...args); } } const app = express(); // Middleware app.use(cors()); app.use(express.json({ limit: '10mb' })); // Database - requires DATABASE_URL environment variable const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) { console.error('ERROR: DATABASE_URL environment variable is required'); process.exit(1); } // OPTIMIZED: Prisma client with connection pooling const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl }, }, log: LOG_LEVEL === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'], }); const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000'; // ── Auth Middleware ────────────────────────────────────────────────────────── const userSessions = {}; const SESSION_TIMEOUT = 3600000; // 1 hour // Cleanup old sessions periodically setInterval(() => { const now = Date.now(); let cleaned = 0; for (const [key, session] of Object.entries(userSessions)) { if (now - new Date(session.lastSeen).getTime() > SESSION_TIMEOUT) { delete userSessions[key]; cleaned++; } } if (cleaned > 0) { log('debug', `Cleaned up ${cleaned} expired sessions`); } }, 600000); // Every 10 minutes app.use(async (req, res, next) => { // Dev mode: no auth required 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 header (recommended) or x-user-id header', }); } // ── Method 1: API Key (recommended) ────────────────────────────── if (apiKey) { const keyUser = await validateApiKey(prisma, apiKey); if (keyUser) { const sessionKey = `key:${keyUser.apiKeyId}`; if (userSessions[sessionKey]) { req.userSession = userSessions[sessionKey]; req.userSession.lastSeen = new Date().toISOString(); } else { req.userSession = { id: randomUUID(), name: `${keyUser.userName} (${keyUser.apiKeyName})`, userId: keyUser.userId, userName: keyUser.userName, apiKeyId: keyUser.apiKeyId, connectedAt: new Date().toISOString(), lastSeen: new Date().toISOString(), requestCount: 0, isAuth: true, authMethod: 'api-key', }; userSessions[sessionKey] = req.userSession; } return next(); } // Fallback: static env var key if (process.env.MCP_API_KEY && apiKey === process.env.MCP_API_KEY) { const sessionKey = `static:${apiKey.substring(0, 8)}`; if (userSessions[sessionKey]) { req.userSession = userSessions[sessionKey]; req.userSession.lastSeen = new Date().toISOString(); } else { req.userSession = { id: randomUUID(), name: 'Static API Key User', connectedAt: new Date().toISOString(), lastSeen: new Date().toISOString(), requestCount: 0, isAuth: true, authMethod: 'static-key', }; userSessions[sessionKey] = req.userSession; } return next(); } return res.status(401).json({ error: 'Invalid API key' }); } // ── Method 2: User ID header (validate against DB) ────────────── if (headerUserId) { const user = await resolveUser(prisma, headerUserId); if (!user) { return res.status(401).json({ error: 'User not found', message: `No user matching: ${headerUserId}` }); } const sessionKey = `user:${user.id}`; if (userSessions[sessionKey]) { req.userSession = userSessions[sessionKey]; req.userSession.lastSeen = new Date().toISOString(); } else { req.userSession = { id: randomUUID(), name: user.name, userId: user.id, userName: user.name, userEmail: user.email, userRole: user.role, connectedAt: new Date().toISOString(), lastSeen: new Date().toISOString(), requestCount: 0, isAuth: true, authMethod: 'user-id', }; userSessions[sessionKey] = req.userSession; } return next(); } return res.status(401).json({ error: 'Authentication failed' }); }); // ── Request Logging ───────────────────────────────────────────────────────── app.use((req, res, next) => { const start = Date.now(); if (req.userSession) { req.userSession.requestCount = (req.userSession.requestCount || 0) + 1; } res.on('finish', () => { const duration = Date.now() - start; const sessionId = req.userSession?.id?.substring(0, 8) || 'anon'; log('debug', `[${sessionId}] ${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`); }); next(); }); // ── Request Timeout Middleware ────────────────────────────────────────────── app.use((req, res, next) => { res.setTimeout(REQUEST_TIMEOUT, () => { log('warn', `Request timeout: ${req.method} ${req.path}`); res.status(504).json({ error: 'Gateway Timeout', message: 'Request took too long' }); }); next(); }); // ── MCP Server Setup ──────────────────────────────────────────────────────── const server = new Server( { name: 'memento-mcp-server', version: '3.1.0', }, { capabilities: { tools: {} }, }, ); registerTools(server, prisma, { userId: process.env.USER_ID || null, appBaseUrl, }); // ── HTTP Endpoints ────────────────────────────────────────────────────────── const transports = {}; // Health check app.get('/', (req, res) => { res.json({ name: 'Memento MCP Server', version: '3.1.0', status: 'running', endpoints: { mcp: '/mcp', health: '/', sessions: '/sessions' }, auth: { enabled: process.env.MCP_REQUIRE_AUTH === 'true', method: 'x-api-key or x-user-id header', }, tools: { notes: 12, notebooks: 6, labels: 4, ai: 11, reminders: 1, apiKeys: 3, total: 37, }, performance: { optimizations: [ 'Connection pooling', 'Batch operations', 'API key caching', 'Request timeout handling', 'Parallel query execution', ], }, }); }); // Session status app.get('/sessions', (req, res) => { const sessions = Object.values(userSessions).map((s) => ({ id: s.id, name: s.name, connectedAt: s.connectedAt, lastSeen: s.lastSeen, requestCount: s.requestCount || 0, })); res.json({ activeUsers: sessions.length, sessions, uptime: process.uptime(), }); }); // MCP endpoint - Streamable HTTP app.all('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id']; let transport; if (sessionId && transports[sessionId]) { transport = transports[sessionId]; } else { transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { log('debug', `Session initialized: ${id}`); transports[id] = transport; }, }); transport.onclose = () => { const sid = transport.sessionId; if (sid && transports[sid]) { log('debug', `Session closed: ${sid}`); delete transports[sid]; } }; await server.connect(transport); } await transport.handleRequest(req, res, req.body); }); // Legacy /sse redirect for backward compat app.all('/sse', async (req, res) => { // Redirect to /mcp req.url = '/mcp'; return app._router.handle(req, res, () => { res.status(404).json({ error: 'Not found' }); }); }); // ── Start Server ──────────────────────────────────────────────────────────── app.listen(PORT, '0.0.0.0', () => { console.log(` ╔═══════════════════════════════════════════════════════════════╗ ║ Memento MCP Server v3.1.0 (Streamable HTTP) - Optimized ║ ╚═══════════════════════════════════════════════════════════════╝ Server: http://localhost:${PORT} MCP: http://localhost:${PORT}/mcp Health: http://localhost:${PORT}/ Sessions: http://localhost:${PORT}/sessions Database: ${databaseUrl} App URL: ${appBaseUrl} User filter: ${process.env.USER_ID || 'none (all data)'} Auth: ${process.env.MCP_REQUIRE_AUTH === 'true' ? 'ENABLED' : 'DISABLED (dev mode)'} Timeout: ${REQUEST_TIMEOUT}ms Performance Optimizations: ✅ Connection pooling ✅ Batch operations ✅ API key caching (60s TTL) ✅ Parallel query execution ✅ Request timeout handling ✅ Session cleanup Tools (37 total): Notes (12): create_note, get_notes, get_note, update_note, delete_note, delete_all_notes, search_notes, move_note, toggle_pin, toggle_archive, export_notes, import_notes Notebooks (6): create_notebook, get_notebooks, get_notebook, update_notebook, delete_notebook, reorder_notebooks Labels (4): create_label, get_labels, update_label, delete_label AI (11): generate_title_suggestions, reformulate_text, generate_tags, suggest_notebook, get_notebook_summary, get_memory_echo, get_note_connections, dismiss_connection, fuse_notes, batch_organize, suggest_auto_labels Reminders (1): get_due_reminders API Key Management (3): generate_api_key, list_api_keys, revoke_api_key N8N config: SSE endpoint http://YOUR_IP:${PORT}/mcp Headers: x-api-key or x-user-id `); }); // Graceful shutdown process.on('SIGINT', async () => { log('info', '\nShutting down MCP server...'); await prisma.$disconnect(); process.exit(0); }); process.on('SIGTERM', async () => { log('info', '\nShutting down MCP server...'); await prisma.$disconnect(); process.exit(0); });