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
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:
@@ -80,6 +80,8 @@ services:
|
|||||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-memento}:${POSTGRES_PASSWORD:-memento}@postgres:5432/${POSTGRES_DB:-memento}
|
- DATABASE_URL=postgresql://${POSTGRES_USER:-memento}:${POSTGRES_PASSWORD:-memento}@postgres:5432/${POSTGRES_DB:-memento}
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- APP_BASE_URL=http://memento-note:3000
|
- APP_BASE_URL=http://memento-note:3000
|
||||||
|
- MCP_MODE=sse
|
||||||
|
- MCP_REQUIRE_AUTH=true
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import express from 'express';
|
|||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { registerTools } from './tools.js';
|
import { registerTools } from './tools.js';
|
||||||
import { validateApiKey, resolveUser } from './auth.js';
|
import { validateApiKey, resolveUser } from './auth.js';
|
||||||
|
import { requestContext } from './request-context.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
@@ -143,6 +144,7 @@ app.use(async (req, res, next) => {
|
|||||||
req.userSession = {
|
req.userSession = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
name: 'Static API Key User',
|
name: 'Static API Key User',
|
||||||
|
userId: process.env.USER_ID || null,
|
||||||
connectedAt: new Date().toISOString(),
|
connectedAt: new Date().toISOString(),
|
||||||
lastSeen: new Date().toISOString(),
|
lastSeen: new Date().toISOString(),
|
||||||
requestCount: 0,
|
requestCount: 0,
|
||||||
@@ -231,7 +233,6 @@ const server = new Server(
|
|||||||
);
|
);
|
||||||
|
|
||||||
registerTools(server, prisma, {
|
registerTools(server, prisma, {
|
||||||
userId: process.env.USER_ID || null,
|
|
||||||
appBaseUrl,
|
appBaseUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -314,7 +315,11 @@ app.all('/mcp', async (req, res) => {
|
|||||||
await server.connect(transport);
|
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);
|
await transport.handleRequest(req, res, req.body);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Legacy /sse redirect for backward compat
|
// Legacy /sse redirect for backward compat
|
||||||
@@ -341,7 +346,7 @@ Sessions: http://localhost:${PORT}/sessions
|
|||||||
|
|
||||||
Database: ${databaseUrl}
|
Database: ${databaseUrl}
|
||||||
App URL: ${appBaseUrl}
|
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)'}
|
Auth: ${process.env.MCP_REQUIRE_AUTH === 'true' ? 'ENABLED' : 'DISABLED (dev mode)'}
|
||||||
Timeout: ${REQUEST_TIMEOUT}ms
|
Timeout: ${REQUEST_TIMEOUT}ms
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ model Note {
|
|||||||
color String @default("default")
|
color String @default("default")
|
||||||
isPinned Boolean @default(false)
|
isPinned Boolean @default(false)
|
||||||
isArchived Boolean @default(false)
|
isArchived Boolean @default(false)
|
||||||
|
trashedAt DateTime?
|
||||||
type String @default("text")
|
type String @default("text")
|
||||||
|
dismissedFromRecent Boolean @default(false)
|
||||||
checkItems String?
|
checkItems String?
|
||||||
labels String?
|
labels String?
|
||||||
images String?
|
images String?
|
||||||
@@ -27,19 +29,30 @@ model Note {
|
|||||||
reminderLocation String?
|
reminderLocation String?
|
||||||
isMarkdown Boolean @default(false)
|
isMarkdown Boolean @default(false)
|
||||||
size String @default("small")
|
size String @default("small")
|
||||||
embedding String?
|
|
||||||
sharedWith String?
|
sharedWith String?
|
||||||
userId String?
|
userId String?
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
notebookId String?
|
notebookId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
contentUpdatedAt DateTime @default(now())
|
||||||
autoGenerated Boolean?
|
autoGenerated Boolean?
|
||||||
aiProvider String?
|
aiProvider String?
|
||||||
aiConfidence Int?
|
aiConfidence Int?
|
||||||
language String?
|
language String?
|
||||||
languageConfidence Float?
|
languageConfidence Float?
|
||||||
lastAiAnalysis DateTime?
|
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 {
|
model Notebook {
|
||||||
@@ -51,6 +64,7 @@ model Notebook {
|
|||||||
userId String
|
userId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
labels Label[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Label {
|
model Label {
|
||||||
@@ -61,6 +75,11 @@ model Label {
|
|||||||
userId String?
|
userId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([notebookId, name])
|
||||||
|
@@index([notebookId])
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -72,6 +91,7 @@ model User {
|
|||||||
role String @default("USER")
|
role String @default("USER")
|
||||||
image String?
|
image String?
|
||||||
theme String @default("light")
|
theme String @default("light")
|
||||||
|
cardSizeMode String @default("variable")
|
||||||
resetToken String? @unique
|
resetToken String? @unique
|
||||||
resetTokenExpiry DateTime?
|
resetTokenExpiry DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -170,7 +190,8 @@ model UserAISettings {
|
|||||||
preferredLanguage String @default("auto")
|
preferredLanguage String @default("auto")
|
||||||
fontSize String @default("medium")
|
fontSize String @default("medium")
|
||||||
demoMode Boolean @default(false)
|
demoMode Boolean @default(false)
|
||||||
showRecentNotes Boolean @default(false)
|
showRecentNotes Boolean @default(true)
|
||||||
|
notesViewMode String @default("masonry")
|
||||||
emailNotifications Boolean @default(false)
|
emailNotifications Boolean @default(false)
|
||||||
desktopNotifications Boolean @default(false)
|
desktopNotifications Boolean @default(false)
|
||||||
anonymousAnalytics Boolean @default(false)
|
anonymousAnalytics Boolean @default(false)
|
||||||
|
|||||||
7
mcp-server/request-context.js
Normal file
7
mcp-server/request-context.js
Normal 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();
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
McpError,
|
McpError,
|
||||||
} from '@modelcontextprotocol/sdk/types.js';
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import { requestContext } from './request-context.js';
|
||||||
|
|
||||||
// ─── Configuration ─────────────────────────────────────────────────────────
|
// ─── Configuration ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -603,22 +604,30 @@ const toolDefinitions = [
|
|||||||
* @param {string} [options.appBaseUrl] - Optional base URL of the Next.js app for AI API calls
|
* @param {string} [options.appBaseUrl] - Optional base URL of the Next.js app for AI API calls
|
||||||
*/
|
*/
|
||||||
export function registerTools(server, prisma, options = {}) {
|
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
|
// Resolve userId per-request from AsyncLocalStorage (set by auth middleware)
|
||||||
let resolvedUserId = userId;
|
const getResolvedUserId = () => {
|
||||||
let userIdPromise = null;
|
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 () => {
|
const ensureUserId = async () => {
|
||||||
if (resolvedUserId) return resolvedUserId;
|
const fromContext = getResolvedUserId();
|
||||||
if (userIdPromise) return userIdPromise;
|
if (fromContext) return fromContext;
|
||||||
|
if (fallbackUserId) return fallbackUserId;
|
||||||
|
if (fallbackPromise) return fallbackPromise;
|
||||||
|
|
||||||
userIdPromise = prisma.user.findFirst({ select: { id: true } }).then(u => {
|
fallbackPromise = prisma.user.findFirst({ select: { id: true } }).then(u => {
|
||||||
if (u) resolvedUserId = u.id;
|
if (u) fallbackUserId = u.id;
|
||||||
return resolvedUserId;
|
return fallbackUserId;
|
||||||
});
|
});
|
||||||
|
|
||||||
return userIdPromise;
|
return fallbackPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── List Tools ────────────────────────────────────────────────────────────
|
// ── List Tools ────────────────────────────────────────────────────────────
|
||||||
@@ -629,6 +638,7 @@ export function registerTools(server, prisma, options = {}) {
|
|||||||
// ── Call Tools ────────────────────────────────────────────────────────────
|
// ── Call Tools ────────────────────────────────────────────────────────────
|
||||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
const { name, arguments: args } = request.params;
|
const { name, arguments: args } = request.params;
|
||||||
|
const resolvedUserId = getResolvedUserId();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
|
|||||||
Reference in New Issue
Block a user