fix: align MCP server schema with memento-note + per-request user isolation
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 14:44:01 +02:00
parent 9779dd7a79
commit 2f1837560b
5 changed files with 63 additions and 18 deletions

View File

@@ -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

View File

@@ -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);
}
// 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

View File

@@ -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)

View File

@@ -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();

View File

@@ -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: if not provided, auto-detect the first user
let resolvedUserId = userId;
let userIdPromise = null;
// 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;
const ensureUserId = async () => {
if (resolvedUserId) return resolvedUserId;
if (userIdPromise) return userIdPromise;
const fromContext = getResolvedUserId();
if (fromContext) return fromContext;
if (fallbackUserId) return fallbackUserId;
if (fallbackPromise) return fallbackPromise;
userIdPromise = prisma.user.findFirst({ select: { id: true } }).then(u => {
if (u) resolvedUserId = u.id;
return resolvedUserId;
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) {