feat: dashboard Second Brain, essai 7 jours et vérification e-mail
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m14s
CI / Deploy production (on server) (push) Successful in 1m25s

Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-08-30 07:19:36 +00:00
parent 69c99e4f4f
commit 80ccc1f6de
95 changed files with 4158 additions and 618 deletions

View File

@@ -6,7 +6,7 @@ Model Context Protocol (MCP) server for integrating Memento note-taking app with
## Features
-**22 Tools** for notes, notebooks, labels, and reminders
-**29 Tools** for notes, notebooks, labels, reminders, semantic similarity, Memory Echo insights, and statistics
- 🔒 **API Key Authentication** with secure storage
- 🚀 **Performance Optimized** with connection pooling and caching
- 📊 **Observability** with Prometheus metrics export
@@ -61,7 +61,7 @@ Generate API keys from the Memento web UI: **Settings > MCP**.
curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
```
## Available Tools (22)
## Available Tools (29)
### Notes (13)
@@ -81,7 +81,7 @@ curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
| `batch_move_notes` | Move multiple notes at once |
| `batch_delete_notes` | Delete multiple notes at once |
### Notebooks (6)
### Notebooks (7)
| Tool | Description |
|------|-------------|
@@ -102,11 +102,28 @@ curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
| `update_label` | Update a label |
| `delete_label` | Delete a label |
### Reminders (1)
### Reminders (3)
| Tool | Description |
|------|-------------|
| `get_due_reminders` | Get due reminders |
| `get_upcoming_reminders` | Get reminders due in the next N hours |
| `update_reminder` | Set, update, or clear a note reminder |
### Semantic & AI (3)
| Tool | Description |
|------|-------------|
| `find_similar_notes` | Find notes semantically similar to a given note |
| `get_memory_echo_insights` | List AI-discovered note connections |
| `dismiss_memory_echo_insight` | Mark a Memory Echo insight as viewed/dismissed |
### Filters & Statistics (2)
| Tool | Description |
|------|-------------|
| `get_notes_by_label` | List notes by label/tag |
| `get_note_statistics` | Get counts and breakdowns for notes, notebooks, labels |
### Utilities (2)

View File

@@ -197,8 +197,9 @@ export function validateConfig() {
}
}
// Port validation
const portValidation = validatePort(config.port);
// Port validation (check raw value before clamping)
const rawPort = Number.parseInt(env('PORT', '3001'), 10);
const portValidation = validatePort(Number.isNaN(rawPort) ? config.port : rawPort);
if (!portValidation.valid) {
errors.push({ key: 'PORT', message: portValidation.error, critical: true });
}

View File

@@ -394,7 +394,7 @@ app.get('/', (req, res) => {
sessions: '/sessions',
},
auth: { enabled: config.requireAuth },
tools: 22,
tools: 29,
uptime: process.uptime(),
});
});
@@ -445,25 +445,23 @@ app.all(
}
// 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;
if (req.body?.method === 'tools/call' && req.body?.params?.name && req.body?.params?.arguments) {
const toolName = req.body.params.name;
const validation = validateAndSanitize(toolName, req.body.params.arguments);
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.arguments = validation.data;
}
const ctx = { userId: req.userSession?.userId || null };
@@ -539,7 +537,7 @@ async function main() {
Auth: ${config.requireAuth ? 'ENABLED' : 'DISABLED (dev)'}
Timeout: ${config.requestTimeout}ms
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
Tools: 22
Tools: 29
Features: ${config.enableMetrics ? 'Metrics' : ''}${config.enableAuditLog ? ', Audit Log' : ''}
`);
});

View File

@@ -176,7 +176,7 @@ async function main() {
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
User: ${config.userId || 'all'}
Log Level: ${config.logLevel}
Tools: 22
Tools: 29
`);
}

View File

@@ -1,7 +1,7 @@
{
"name": "memento-mcp-server",
"version": "3.2.0",
"description": "MCP Server for Memento - AI-powered note-taking app. Enhanced with error handling, metrics, rate limiting, and input validation. Provides 22 tools for notes, notebooks, labels, and reminders.",
"description": "MCP Server for Memento - AI-powered note-taking app. Enhanced with error handling, metrics, rate limiting, and input validation. Provides 29 tools for notes, notebooks, labels, reminders, semantic similarity, Memory Echo insights, and statistics.",
"type": "module",
"main": "index.js",
"scripts": {

View File

@@ -5,7 +5,7 @@
* Run with: npm test
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
@@ -124,9 +124,8 @@ describe('MCP Server - Input Validation', () => {
it('should allow safe HTML', () => {
const xss = checkXSS({ content: 'Hello <em>world</em>' });
// This will be true because we check for any HTML tags
// In production, you might want more sophisticated checking
expect(xss).toBe(true);
// Safe formatting tags are not flagged as XSS
expect(xss).toBe(false);
});
it('should sanitize input', () => {
@@ -206,11 +205,18 @@ describe('MCP Server - Tool Definitions', () => {
'update_label',
'delete_label',
'get_due_reminders',
'get_upcoming_reminders',
'update_reminder',
'find_similar_notes',
'get_memory_echo_insights',
'dismiss_memory_echo_insight',
'get_notes_by_label',
'get_note_statistics',
'export_notes',
'import_notes',
];
it('should have all expected tools with schemas', () => {
it('should have all expected tools with schemas', async () => {
const { toolSchemas } = await import('../validation.js');
for (const toolName of toolNames) {

View File

@@ -19,7 +19,7 @@ const DEFAULT_NOTES_LIMIT = 100;
const MAX_NOTES_LIMIT = 500;
const NOTE_COLORS = 'default, red, orange, yellow, green, teal, blue, purple, pink, gray';
const LABEL_COLORS = 'red, orange, yellow, green, teal, blue, purple, pink, gray';
const LABEL_COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
export function parseNote(dbNote) {
if (!dbNote) return null;
@@ -463,6 +463,100 @@ const toolDefinitions = [
description: 'Get notes with due reminders. Designed for cron/automation.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'get_upcoming_reminders',
description: 'Get notes with reminders due in the next N hours (default 24).',
inputSchema: {
type: 'object',
properties: {
hours: { type: 'number', description: 'Number of hours ahead to look', default: 24 },
includeDone: { type: 'boolean', description: 'Include already-done reminders', default: false },
limit: { type: 'number', description: 'Max results', default: 100 },
},
},
},
{
name: 'update_reminder',
description: 'Set, update, or clear a reminder on a note.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Note ID' },
reminder: { type: 'string', description: 'ISO 8601 datetime, or null to clear', nullable: true },
isReminderDone: { type: 'boolean' },
reminderRecurrence: { type: 'string', description: 'daily, weekly, monthly, yearly', nullable: true },
reminderLocation: { type: 'string', nullable: true },
},
required: ['id'],
},
},
// ═══ SEMANTIC / AI ═══
{
name: 'find_similar_notes',
description: 'Find notes semantically similar to a given note using its vector embedding. Requires the note to have an embedding.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Source note ID' },
limit: { type: 'number', description: 'Max results', default: 10 },
threshold: { type: 'number', description: 'Minimum cosine similarity (0-1)', default: 0.7 },
},
required: ['id'],
},
},
{
name: 'get_memory_echo_insights',
description: 'List Memory Echo insights: AI-discovered connections between notes.',
inputSchema: {
type: 'object',
properties: {
includeViewed: { type: 'boolean', description: 'Include already viewed insights', default: false },
includeDismissed: { type: 'boolean', description: 'Include dismissed insights', default: false },
limit: { type: 'number', description: 'Max results', default: 20 },
},
},
},
{
name: 'dismiss_memory_echo_insight',
description: 'Mark a Memory Echo insight as viewed or dismissed.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Insight ID' },
viewed: { type: 'boolean', description: 'Mark as viewed', default: true },
dismissed: { type: 'boolean', description: 'Mark as dismissed', default: true },
},
required: ['id'],
},
},
// ═══ FILTERS & STATISTICS ═══
{
name: 'get_notes_by_label',
description: 'List notes that have a specific label/tag.',
inputSchema: {
type: 'object',
properties: {
label: { type: 'string', description: 'Label text to match' },
notebookId: { type: 'string', description: 'Filter by notebook', nullable: true },
includeArchived: { type: 'boolean', default: false },
limit: { type: 'number', description: 'Max results', default: 100 },
},
required: ['label'],
},
},
{
name: 'get_note_statistics',
description: 'Get statistics about notes, notebooks, labels, reminders, and trash.',
inputSchema: {
type: 'object',
properties: {
includeArchived: { type: 'boolean', description: 'Include archived notes in counts', default: false },
includeTrashed: { type: 'boolean', description: 'Include trashed notes in counts', default: false },
},
},
},
];
// ─── Tool Handlers ──────────────────────────────────────────────────────────
@@ -502,7 +596,7 @@ export function registerTools(server, prisma) {
title: args.title || null,
content: args.content,
color: args.color || 'default',
type: args.type || 'text',
type: args.type || 'richtext',
checkItems: args.checkItems ?? null,
labels: args.labels ?? null,
isPinned: args.isPinned || false,
@@ -1003,6 +1097,201 @@ export function registerTools(server, prisma) {
return textResult({ count: reminders.length, reminders });
}
case 'get_upcoming_reminders': {
const now = new Date();
const horizon = new Date(now.getTime() + (args.hours || 24) * 60 * 60 * 1000);
const extra = {
reminder: { not: null, gte: now, lte: horizon },
isArchived: false,
};
if (!args.includeDone) extra.isReminderDone = false;
const reminders = await prisma.note.findMany({
where: noteWhere(uid, extra),
select: { id: true, title: true, content: true, reminder: true, isReminderDone: true, notebookId: true },
orderBy: { reminder: 'asc' },
take: args.limit || 100,
});
return textResult({ count: reminders.length, horizon, reminders });
}
case 'update_reminder': {
const d = { updatedAt: new Date() };
if ('reminder' in args) d.reminder = args.reminder ? new Date(args.reminder) : null;
if ('isReminderDone' in args) d.isReminderDone = args.isReminderDone;
if ('reminderRecurrence' in args) d.reminderRecurrence = args.reminderRecurrence || null;
if ('reminderLocation' in args) d.reminderLocation = args.reminderLocation || null;
const note = await prisma.note.update({
where: { id: args.id, userId: uid, trashedAt: null },
data: d,
});
return textResult(parseNote(note));
}
// ═══ SEMANTIC / AI ═══
case 'find_similar_notes': {
const source = await prisma.note.findUnique({
where: { id: args.id, userId: uid, trashedAt: null },
select: { id: true, title: true },
});
if (!source) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
const sourceEmbedding = await prisma.noteEmbedding.findUnique({
where: { noteId: args.id },
select: { embedding: true },
});
if (!sourceEmbedding) {
return textResult({
noteId: args.id,
error: 'Source note has no embedding. Index it first via the Memento app.',
results: [],
});
}
const embeddingArray = Array.isArray(sourceEmbedding.embedding)
? sourceEmbedding.embedding
: String(sourceEmbedding.embedding).replace(/^\[|\]$/g, '').split(',').map(Number);
const vecStr = `[${embeddingArray.join(',')}]`;
const limit = Math.min(args.limit || 10, 50);
const threshold = args.threshold ?? 0.7;
const rows = await prisma.$queryRawUnsafe(
`SELECT n.id, n.title, n.content, n.color, n.type, n."isPinned", n."isArchived",
n."notebookId", n."createdAt", n."updatedAt",
1 - (e."embedding"::vector <=> $1::vector) AS similarity
FROM "Note" n
INNER JOIN "NoteEmbedding" e ON e."noteId" = n.id
WHERE n."trashedAt" IS NULL
AND n."isArchived" = false
AND n."userId" = $2
AND n.id != $3
AND 1 - (e."embedding"::vector <=> $1::vector) >= $4
ORDER BY e."embedding"::vector <=> $1::vector ASC
LIMIT $5`,
vecStr,
uid,
args.id,
threshold,
limit,
);
return textResult({
noteId: args.id,
sourceTitle: source.title || 'Untitled',
count: rows.length,
results: rows.map(r => ({ ...parseNoteLightweight(r), similarity: Number(r.similarity) })),
});
}
case 'get_memory_echo_insights': {
const where = { userId: uid };
if (!args.includeViewed) where.viewed = false;
if (!args.includeDismissed) where.dismissed = false;
const insights = await prisma.memoryEchoInsight.findMany({
where,
orderBy: { similarityScore: 'desc' },
take: Math.min(args.limit || 20, 100),
include: {
note1: { select: { id: true, title: true } },
note2: { select: { id: true, title: true } },
},
});
return textResult({
count: insights.length,
insights: insights.map(i => ({
id: i.id,
insight: i.insight,
similarityScore: i.similarityScore,
insightDate: i.insightDate,
viewed: i.viewed,
dismissed: i.dismissed,
note1: i.note1,
note2: i.note2,
})),
});
}
case 'dismiss_memory_echo_insight': {
const d = {};
if ('viewed' in args) d.viewed = args.viewed;
if ('dismissed' in args) d.dismissed = args.dismissed;
const insight = await prisma.memoryEchoInsight.update({
where: { id: args.id, userId: uid },
data: d,
});
return textResult({ success: true, id: insight.id, viewed: insight.viewed, dismissed: insight.dismissed });
}
// ═══ FILTERS & STATISTICS ═══
case 'get_notes_by_label': {
const label = args.label.trim();
const extra = {};
if (!args.includeArchived) extra.isArchived = false;
if (args.notebookId) {
extra.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
}
// Labels are stored as a JSON string like ["tag1","tag2"].
const notes = await prisma.note.findMany({
where: {
...noteWhere(uid, extra),
labels: { contains: label },
},
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
take: Math.min(args.limit || 100, MAX_NOTES_LIMIT),
});
return textResult({ label, count: notes.length, notes: notes.map(parseNoteLightweight) });
}
case 'get_note_statistics': {
const baseWhere = { userId: uid };
const activeWhere = { ...baseWhere, trashedAt: null };
const [
totalNotes,
archivedNotes,
trashedNotes,
notebookCount,
labelCount,
notesWithReminders,
notesWithImages,
notesWithCheckItems,
notesByType,
notesByColor,
] = await Promise.all([
prisma.note.count({ where: activeWhere }),
prisma.note.count({ where: { ...activeWhere, isArchived: true } }),
prisma.note.count({ where: { ...baseWhere, trashedAt: { not: null } } }),
prisma.notebook.count({ where: { userId: uid } }),
prisma.label.count({ where: { notebook: { userId: uid } } }),
prisma.note.count({ where: { ...activeWhere, reminder: { not: null } } }),
prisma.note.count({ where: { ...activeWhere, images: { not: null } } }),
prisma.note.count({ where: { ...activeWhere, checkItems: { not: null } } }),
prisma.note.groupBy({ by: ['type'], where: activeWhere, _count: { type: true } }),
prisma.note.groupBy({ by: ['color'], where: activeWhere, _count: { color: true } }),
]);
return textResult({
totalNotes,
archivedNotes,
trashedNotes,
activeNotes: totalNotes - archivedNotes,
notebookCount,
labelCount,
notesWithReminders,
notesWithImages,
notesWithCheckItems,
byType: notesByType.map(t => ({ type: t.type, count: t._count.type })),
byColor: notesByColor.map(c => ({ color: c.color, count: c._count.color })),
});
}
// ═══ TRASH ═══
case 'trash_note': {
const note = await prisma.note.update({

View File

@@ -179,7 +179,6 @@ export const getNotesSchema = z.object({
notebookId: idSchema.optional().nullable(),
fullDetails: boolSchema(false),
limit: z.number().int().min(1).max(500).default(100),
offset: z.number().int().min(0).default(0),
});
/**
@@ -225,7 +224,6 @@ export const deleteNoteSchema = z.object({
*/
export const searchNotesSchema = z.object({
query: z.string().min(1).max(500),
limit: z.number().int().min(1).max(100).default(50),
notebookId: idSchema.optional().nullable(),
includeArchived: boolSchema(false),
});
@@ -243,7 +241,6 @@ export const moveNoteSchema = z.object({
*/
export const togglePinSchema = z.object({
id: idSchema,
pinned: z.boolean().optional(),
});
/**
@@ -251,14 +248,13 @@ export const togglePinSchema = z.object({
*/
export const toggleArchiveSchema = z.object({
id: idSchema,
archived: z.boolean().optional(),
});
/**
* batch_move_notes input schema
*/
export const batchMoveNotesSchema = z.object({
noteIds: z.array(idSchema).min(1).max(100),
ids: z.array(idSchema).min(1).max(100),
notebookId: idSchema.optional().nullable(),
});
@@ -266,7 +262,7 @@ export const batchMoveNotesSchema = z.object({
* batch_delete_notes input schema
*/
export const batchDeleteNotesSchema = z.object({
noteIds: z.array(idSchema).min(1).max(100),
ids: z.array(idSchema).min(1).max(100),
});
/**
@@ -274,18 +270,16 @@ export const batchDeleteNotesSchema = z.object({
*/
export const createNotebookSchema = z.object({
name: z.string().min(1).max(200),
color: colorSchema.default('default'),
color: z.string().max(50).default('#3B82F6'),
icon: z.string().max(50).optional().nullable(),
parentId: idSchema.optional().nullable(),
order: z.number().int().optional(),
});
/**
* get_notebooks input schema
*/
export const getNotebooksSchema = z.object({
includeHierarchy: boolSchema(false),
includeTrashed: boolSchema(false),
});
export const getNotebooksSchema = z.object({});
/**
* get_notebook input schema
@@ -300,8 +294,9 @@ export const getNotebookSchema = z.object({
export const updateNotebookSchema = z.object({
id: idSchema,
name: z.string().min(1).max(200).optional(),
color: colorSchema.optional(),
color: z.string().max(50).optional().nullable(),
icon: z.string().max(50).optional().nullable(),
order: z.number().int().optional(),
parentId: idSchema.optional().nullable(),
});
@@ -332,14 +327,15 @@ export const getNotebookHierarchySchema = z.object({
*/
export const createLabelSchema = z.object({
name: z.string().min(1).max(100),
color: colorSchema.default('default'),
color: z.string().max(50).optional().nullable(),
notebookId: idSchema,
});
/**
* get_labels input schema
*/
export const getLabelsSchema = z.object({
limit: z.number().int().min(1).max(500).default(100),
notebookId: idSchema.optional().nullable(),
});
/**
@@ -361,37 +357,90 @@ export const deleteLabelSchema = z.object({
/**
* get_due_reminders input schema
*/
export const getDueRemindersSchema = z.object({
before: isoDateSchema.optional().nullable(),
after: isoDateSchema.optional().nullable(),
includeDone: boolSchema(false),
limit: z.number().int().min(1).max(500).default(100),
});
export const getDueRemindersSchema = z.object({});
/**
* export_notes input schema
*/
export const exportNotesSchema = z.object({
notebookId: idSchema.optional().nullable(),
includeArchived: boolSchema(false),
format: z.enum(['json', 'markdown']).default('json'),
});
export const exportNotesSchema = z.object({});
/**
* import_notes input schema
*/
export const importNotesSchema = z.object({
notes: z.array(
z.object({
title: z.string().optional(),
content: z.string(),
color: colorSchema.optional(),
labels: labelsSchema,
notebookId: idSchema.optional().nullable(),
})
).min(1).max(100),
data: z.object({
version: z.string().optional(),
data: z.object({
notes: z.array(z.any()).optional(),
labels: z.array(z.any()).optional(),
notebooks: z.array(z.any()).optional(),
}).optional(),
}),
});
/**
* find_similar_notes input schema
*/
export const findSimilarNotesSchema = z.object({
id: idSchema,
limit: z.number().int().min(1).max(50).default(10),
threshold: z.number().min(0).max(1).default(0.7),
});
/**
* get_memory_echo_insights input schema
*/
export const getMemoryEchoInsightsSchema = z.object({
includeViewed: boolSchema(false),
includeDismissed: boolSchema(false),
limit: z.number().int().min(1).max(100).default(20),
});
/**
* dismiss_memory_echo_insight input schema
*/
export const dismissMemoryEchoInsightSchema = z.object({
id: idSchema,
viewed: boolSchema(true),
dismissed: boolSchema(true),
});
/**
* get_notes_by_label input schema
*/
export const getNotesByLabelSchema = z.object({
label: z.string().min(1).max(100),
notebookId: idSchema.optional().nullable(),
overwrite: z.boolean().optional().default(false),
includeArchived: boolSchema(false),
limit: z.number().int().min(1).max(500).default(100),
});
/**
* get_note_statistics input schema
*/
export const getNoteStatisticsSchema = z.object({
includeArchived: boolSchema(false),
includeTrashed: boolSchema(false),
});
/**
* get_upcoming_reminders input schema
*/
export const getUpcomingRemindersSchema = z.object({
hours: z.number().int().min(1).max(168).default(24),
includeDone: boolSchema(false),
limit: z.number().int().min(1).max(500).default(100),
});
/**
* update_reminder input schema
*/
export const updateReminderSchema = z.object({
id: idSchema,
reminder: isoDateSchema,
isReminderDone: z.boolean().optional(),
reminderRecurrence: recurrenceSchema,
reminderLocation: z.string().max(500).optional().nullable(),
});
// ═══════════════════════════════════════════════════════════════
@@ -427,6 +476,13 @@ export const toolSchemas = {
get_due_reminders: getDueRemindersSchema,
export_notes: exportNotesSchema,
import_notes: importNotesSchema,
find_similar_notes: findSimilarNotesSchema,
get_memory_echo_insights: getMemoryEchoInsightsSchema,
dismiss_memory_echo_insight: dismissMemoryEchoInsightSchema,
get_notes_by_label: getNotesByLabelSchema,
get_note_statistics: getNoteStatisticsSchema,
get_upcoming_reminders: getUpcomingRemindersSchema,
update_reminder: updateReminderSchema,
};
// ═══════════════════════════════════════════════════════════════
@@ -457,7 +513,7 @@ export function validateToolInput(toolName, input) {
if (error instanceof z.ZodError) {
return {
success: false,
errors: error.errors.map((e) => ({
errors: error.issues.map((e) => ({
field: e.path.join('.'),
message: e.message,
code: e.code,

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/test.js', 'test/**/*.test.js'],
},
});