From 2f1837560bfe073f85236038a10742ff858b92d3 Mon Sep 17 00:00:00 2001 From: sepehr Date: Sun, 26 Apr 2026 14:44:01 +0200 Subject: [PATCH] fix: align MCP server schema with memento-note + per-request user isolation - Remove `embedding` column from MCP Note model (dropped by migration 20260425120000) - Add missing columns: trashedAt, dismissedFromRecent, contentUpdatedAt, cardSizeMode - Add NoteEmbedding model and Label.notebook relation - Use AsyncLocalStorage to pass authenticated userId from API key to tool handlers - Enable SSE mode and auth in docker-compose for N8N integration Co-Authored-By: Claude Opus 4.7 --- docker-compose.yml | 2 ++ mcp-server/index-sse.js | 11 +++++++--- mcp-server/prisma/schema.prisma | 25 +++++++++++++++++++++-- mcp-server/request-context.js | 7 +++++++ mcp-server/tools.js | 36 +++++++++++++++++++++------------ 5 files changed, 63 insertions(+), 18 deletions(-) create mode 100644 mcp-server/request-context.js diff --git a/docker-compose.yml b/docker-compose.yml index bfca60e..b7c0cd6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,6 +80,8 @@ services: - DATABASE_URL=postgresql://${POSTGRES_USER:-memento}:${POSTGRES_PASSWORD:-memento}@postgres:5432/${POSTGRES_DB:-memento} - NODE_ENV=production - APP_BASE_URL=http://memento-note:3000 + - MCP_MODE=sse + - MCP_REQUIRE_AUTH=true depends_on: postgres: condition: service_healthy diff --git a/mcp-server/index-sse.js b/mcp-server/index-sse.js index f62205c..df32806 100644 --- a/mcp-server/index-sse.js +++ b/mcp-server/index-sse.js @@ -30,6 +30,7 @@ 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'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -143,6 +144,7 @@ app.use(async (req, res, next) => { req.userSession = { id: randomUUID(), name: 'Static API Key User', + userId: process.env.USER_ID || null, connectedAt: new Date().toISOString(), lastSeen: new Date().toISOString(), requestCount: 0, @@ -231,7 +233,6 @@ const server = new Server( ); registerTools(server, prisma, { - userId: process.env.USER_ID || null, appBaseUrl, }); @@ -314,7 +315,11 @@ app.all('/mcp', async (req, res) => { await server.connect(transport); } - await transport.handleRequest(req, res, req.body); + // Pass authenticated userId to tool handlers via AsyncLocalStorage + const ctx = { userId: req.userSession?.userId || null }; + await requestContext.run(ctx, async () => { + await transport.handleRequest(req, res, req.body); + }); }); // Legacy /sse redirect for backward compat @@ -341,7 +346,7 @@ Sessions: http://localhost:${PORT}/sessions Database: ${databaseUrl} App URL: ${appBaseUrl} -User filter: ${process.env.USER_ID || 'none (all data)'} +User filter: per-request (from auth) Auth: ${process.env.MCP_REQUIRE_AUTH === 'true' ? 'ENABLED' : 'DISABLED (dev mode)'} Timeout: ${REQUEST_TIMEOUT}ms diff --git a/mcp-server/prisma/schema.prisma b/mcp-server/prisma/schema.prisma index b79043a..d19c899 100644 --- a/mcp-server/prisma/schema.prisma +++ b/mcp-server/prisma/schema.prisma @@ -16,7 +16,9 @@ model Note { color String @default("default") isPinned Boolean @default(false) isArchived Boolean @default(false) + trashedAt DateTime? type String @default("text") + dismissedFromRecent Boolean @default(false) checkItems String? labels String? images String? @@ -27,19 +29,30 @@ model Note { reminderLocation String? isMarkdown Boolean @default(false) size String @default("small") - embedding String? sharedWith String? userId String? order Int @default(0) notebookId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + contentUpdatedAt DateTime @default(now()) autoGenerated Boolean? aiProvider String? aiConfidence Int? language String? languageConfidence Float? lastAiAnalysis DateTime? + noteEmbedding NoteEmbedding? +} + +model NoteEmbedding { + id String @id @default(cuid()) + noteId String @unique + embedding String + createdAt DateTime @default(now()) + note Note @relation(fields: [noteId], references: [id], onDelete: Cascade) + + @@index([noteId]) } model Notebook { @@ -51,6 +64,7 @@ model Notebook { userId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + labels Label[] } model Label { @@ -61,6 +75,11 @@ model Label { userId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: Cascade) + + @@unique([notebookId, name]) + @@index([notebookId]) + @@index([userId]) } model User { @@ -72,6 +91,7 @@ model User { role String @default("USER") image String? theme String @default("light") + cardSizeMode String @default("variable") resetToken String? @unique resetTokenExpiry DateTime? createdAt DateTime @default(now()) @@ -170,7 +190,8 @@ model UserAISettings { preferredLanguage String @default("auto") fontSize String @default("medium") demoMode Boolean @default(false) - showRecentNotes Boolean @default(false) + showRecentNotes Boolean @default(true) + notesViewMode String @default("masonry") emailNotifications Boolean @default(false) desktopNotifications Boolean @default(false) anonymousAnalytics Boolean @default(false) diff --git a/mcp-server/request-context.js b/mcp-server/request-context.js new file mode 100644 index 0000000..530e560 --- /dev/null +++ b/mcp-server/request-context.js @@ -0,0 +1,7 @@ +import { AsyncLocalStorage } from 'async_hooks'; + +/** + * Per-request context storage for passing authenticated userId + * from Express middleware to MCP tool handlers. + */ +export const requestContext = new AsyncLocalStorage(); diff --git a/mcp-server/tools.js b/mcp-server/tools.js index 8966818..96fc66a 100644 --- a/mcp-server/tools.js +++ b/mcp-server/tools.js @@ -18,6 +18,7 @@ import { ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; +import { requestContext } from './request-context.js'; // ─── Configuration ───────────────────────────────────────────────────────── @@ -603,22 +604,30 @@ const toolDefinitions = [ * @param {string} [options.appBaseUrl] - Optional base URL of the Next.js app for AI API calls */ export function registerTools(server, prisma, options = {}) { - const { userId = null, appBaseUrl = null } = options; + const { appBaseUrl = null } = options; + + // Resolve userId per-request from AsyncLocalStorage (set by auth middleware) + const getResolvedUserId = () => { + const store = requestContext.getStore(); + return store?.userId || null; + }; + + // Fallback: auto-detect first user when no auth context + let fallbackUserId = null; + let fallbackPromise = null; - // Resolve userId: if not provided, auto-detect the first user - let resolvedUserId = userId; - let userIdPromise = null; - const ensureUserId = async () => { - if (resolvedUserId) return resolvedUserId; - if (userIdPromise) return userIdPromise; - - userIdPromise = prisma.user.findFirst({ select: { id: true } }).then(u => { - if (u) resolvedUserId = u.id; - return resolvedUserId; + const fromContext = getResolvedUserId(); + if (fromContext) return fromContext; + if (fallbackUserId) return fallbackUserId; + if (fallbackPromise) return fallbackPromise; + + fallbackPromise = prisma.user.findFirst({ select: { id: true } }).then(u => { + if (u) fallbackUserId = u.id; + return fallbackUserId; }); - - return userIdPromise; + + return fallbackPromise; }; // ── List Tools ──────────────────────────────────────────────────────────── @@ -629,6 +638,7 @@ export function registerTools(server, prisma, options = {}) { // ── Call Tools ──────────────────────────────────────────────────────────── server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; + const resolvedUserId = getResolvedUserId(); try { switch (name) {