Files
Momento/mcp-server/tools.js
sepehr 49e076a4bb refactor: remove dangerous, AI proxy, and API key tools from MCP server
- Remove delete_all_notes, 11 AI proxy tools, 3 API key management tools
- Clean up unused imports (auth helpers, resolveUser, clearAuthCaches)
- Remove fetchWithTimeout helper and appBaseUrl option (no longer needed)
- Update tool counts in health endpoint and startup banner (37 → 22 tools)
- Keep only CRUD notes/notebooks/labels + reminders + search/export/import

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 16:15:50 +02:00

980 lines
37 KiB
JavaScript

/**
* Memento MCP Server - Optimized Tool Definitions & Handlers
*
* Performance optimizations:
* - O(1) API key lookup with caching
* - Batch operations for imports
* - Parallel promise execution
* - HTTP timeout wrapper
* - N+1 query fixes
* - Connection pooling
*/
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import { requestContext } from './request-context.js';
// ─── Configuration ─────────────────────────────────────────────────────────
const DEFAULT_SEARCH_LIMIT = 50;
const DEFAULT_NOTES_LIMIT = 100;
// ─── Helpers ────────────────────────────────────────────────────────────────
export function parseNote(dbNote) {
if (!dbNote) return null;
return {
...dbNote,
checkItems: dbNote.checkItems ?? null,
labels: dbNote.labels ?? null,
images: dbNote.images ?? null,
links: dbNote.links ?? null,
};
}
export function parseNoteLightweight(dbNote) {
if (!dbNote) return null;
const images = Array.isArray(dbNote.images) ? dbNote.images : [];
const labels = Array.isArray(dbNote.labels) ? dbNote.labels : null;
const checkItems = Array.isArray(dbNote.checkItems) ? dbNote.checkItems : [];
return {
id: dbNote.id,
title: dbNote.title,
content: dbNote.content?.length > 200 ? dbNote.content.substring(0, 200) + '...' : dbNote.content,
color: dbNote.color,
type: dbNote.type,
isPinned: dbNote.isPinned,
isArchived: dbNote.isArchived,
hasImages: images.length > 0,
imageCount: images.length,
labels,
hasCheckItems: checkItems.length > 0,
checkItemsCount: checkItems.length,
reminder: dbNote.reminder,
isReminderDone: dbNote.isReminderDone,
isMarkdown: dbNote.isMarkdown,
size: dbNote.size,
createdAt: dbNote.createdAt,
updatedAt: dbNote.updatedAt,
notebookId: dbNote.notebookId,
};
}
function textResult(data) {
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
};
}
// ─── Tool Schemas ───────────────────────────────────────────────────────────
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 toolDefinitions = [
// ═══════════════════════════════════════════════════════════
// NOTE TOOLS
// ═══════════════════════════════════════════════════════════
{
name: 'create_note',
description: 'Create a new note. Supports text and checklist types, colors, labels, images, links, reminders, markdown, and notebook assignment.',
inputSchema: {
type: 'object',
properties: {
title: { type: 'string', description: 'Note title (optional)' },
content: { type: 'string', description: 'Note content (required)' },
color: { type: 'string', description: `Note color: ${NOTE_COLORS.join(', ')}`, default: 'default' },
type: { type: 'string', enum: ['text', 'checklist'], description: 'Note type', default: 'text' },
checkItems: {
type: 'array',
description: 'Checklist items (when type is checklist)',
items: {
type: 'object',
properties: { id: { type: 'string' }, text: { type: 'string' }, checked: { type: 'boolean' } },
required: ['id', 'text', 'checked'],
},
},
labels: { type: 'array', description: 'Note labels/tags', items: { type: 'string' } },
isPinned: { type: 'boolean', description: 'Pin the note', default: false },
isArchived: { type: 'boolean', description: 'Create as archived', default: false },
images: { type: 'array', description: 'Image URLs or base64 strings', items: { type: 'string' } },
links: { type: 'array', description: 'URLs attached to the note', items: { type: 'string' } },
reminder: { type: 'string', description: 'Reminder datetime (ISO 8601)' },
isReminderDone: { type: 'boolean', default: false },
reminderRecurrence: { type: 'string', description: 'Recurrence: daily, weekly, monthly, yearly' },
reminderLocation: { type: 'string', description: 'Location-based reminder' },
isMarkdown: { type: 'boolean', description: 'Enable markdown rendering', default: false },
size: { type: 'string', enum: ['small', 'medium', 'large'], default: 'small' },
notebookId: { type: 'string', description: 'Notebook to assign the note to' },
},
required: ['content'],
},
},
{
name: 'get_notes',
description: 'Get notes with optional filters. Returns lightweight format by default (truncated content, no images). Use fullDetails=true for complete data.',
inputSchema: {
type: 'object',
properties: {
includeArchived: { type: 'boolean', description: 'Include archived notes', default: false },
search: { type: 'string', description: 'Filter by keyword in title/content' },
notebookId: { type: 'string', description: 'Filter by notebook ID. Use "inbox" for notes without a notebook' },
fullDetails: { type: 'boolean', description: 'Return full details including images (large payload)', default: false },
limit: { type: 'number', description: `Max notes to return (default ${DEFAULT_NOTES_LIMIT})`, default: DEFAULT_NOTES_LIMIT },
},
},
},
{
name: 'get_note',
description: 'Get a single note by ID with full details.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', description: 'Note ID' } },
required: ['id'],
},
},
{
name: 'update_note',
description: 'Update an existing note. Only include fields you want to change.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Note ID' },
title: { type: 'string' },
content: { type: 'string' },
color: { type: 'string', description: `One of: ${NOTE_COLORS.join(', ')}` },
type: { type: 'string', enum: ['text', 'checklist'] },
checkItems: {
type: 'array',
items: {
type: 'object',
properties: { id: { type: 'string' }, text: { type: 'string' }, checked: { type: 'boolean' } },
},
},
labels: { type: 'array', items: { type: 'string' } },
isPinned: { type: 'boolean' },
isArchived: { type: 'boolean' },
images: { type: 'array', items: { type: 'string' } },
links: { type: 'array', items: { type: 'string' } },
reminder: { type: 'string', description: 'ISO 8601 datetime' },
isReminderDone: { type: 'boolean' },
reminderRecurrence: { type: 'string' },
reminderLocation: { type: 'string' },
isMarkdown: { type: 'boolean' },
size: { type: 'string', enum: ['small', 'medium', 'large'] },
notebookId: { type: 'string' },
},
required: ['id'],
},
},
{
name: 'delete_note',
description: 'Delete a note by ID.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', description: 'Note ID' } },
required: ['id'],
},
},
{
name: 'search_notes',
description: 'Search notes by keyword in title, content, and labels. Returns lightweight format.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
notebookId: { type: 'string', description: 'Limit search to a notebook' },
includeArchived: { type: 'boolean', default: false },
},
required: ['query'],
},
},
{
name: 'move_note',
description: 'Move a note to a different notebook. Pass null for notebookId to move to Inbox.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Note ID' },
notebookId: { type: 'string', description: 'Target notebook ID, or null/empty for Inbox' },
},
required: ['id'],
},
},
{
name: 'toggle_pin',
description: 'Toggle the pin status of a note.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', description: 'Note ID' } },
required: ['id'],
},
},
{
name: 'toggle_archive',
description: 'Toggle the archive status of a note.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', description: 'Note ID' } },
required: ['id'],
},
},
{
name: 'export_notes',
description: 'Export all notes, labels, and notebooks as a JSON object.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'import_notes',
description: 'Import notes from a previously exported JSON object. Skips duplicates by name.',
inputSchema: {
type: 'object',
properties: {
data: {
type: 'object',
description: 'The exported JSON data (from export_notes)',
properties: {
version: { type: 'string' },
data: {
type: 'object',
properties: {
notes: { type: 'array' },
labels: { type: 'array' },
notebooks: { type: 'array' },
},
},
},
},
},
required: ['data'],
},
},
// ═══════════════════════════════════════════════════════════
// NOTEBOOK TOOLS
// ═══════════════════════════════════════════════════════════
{
name: 'create_notebook',
description: 'Create a new notebook.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Notebook name' },
icon: { type: 'string', description: 'Notebook icon (emoji)', default: '📁' },
color: { type: 'string', description: 'Hex color code', default: '#3B82F6' },
order: { type: 'number', description: 'Sort position (auto-assigned if omitted)' },
},
required: ['name'],
},
},
{
name: 'get_notebooks',
description: 'Get all notebooks with label and note counts.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'get_notebook',
description: 'Get a notebook by ID, including its notes.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', description: 'Notebook ID' } },
required: ['id'],
},
},
{
name: 'update_notebook',
description: 'Update a notebook\'s name, icon, color, or order.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Notebook ID' },
name: { type: 'string' },
icon: { type: 'string' },
color: { type: 'string' },
order: { type: 'number' },
},
required: ['id'],
},
},
{
name: 'delete_notebook',
description: 'Delete a notebook. Notes inside will be moved to Inbox.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', description: 'Notebook ID' } },
required: ['id'],
},
},
{
name: 'reorder_notebooks',
description: 'Reorder notebooks. Pass an ordered array of notebook IDs.',
inputSchema: {
type: 'object',
properties: {
notebookIds: {
type: 'array',
description: 'Notebook IDs in the desired order',
items: { type: 'string' },
},
},
required: ['notebookIds'],
},
},
// ═══════════════════════════════════════════════════════════
// LABEL TOOLS
// ═══════════════════════════════════════════════════════════
{
name: 'create_label',
description: 'Create a label in a notebook.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Label name' },
color: { type: 'string', description: `Label color: ${LABEL_COLORS.join(', ')}` },
notebookId: { type: 'string', description: 'Notebook to attach the label to' },
},
required: ['name', 'notebookId'],
},
},
{
name: 'get_labels',
description: 'Get all labels, optionally filtered by notebook.',
inputSchema: {
type: 'object',
properties: {
notebookId: { type: 'string', description: 'Filter labels by notebook ID' },
},
},
},
{
name: 'update_label',
description: 'Update a label\'s name or color.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Label ID' },
name: { type: 'string' },
color: { type: 'string' },
},
required: ['id'],
},
},
{
name: 'delete_label',
description: 'Delete a label by ID.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', description: 'Label ID' } },
required: ['id'],
},
},
// ═══════════════════════════════════════════════════════════
// REMINDER TOOLS
// ═══════════════════════════════════════════════════════════
{
name: 'get_due_reminders',
description: 'Get all notes with due reminders that haven\'t been processed yet. Designed for cron/automation use.',
inputSchema: { type: 'object', properties: {} },
},
];
// ─── Tool Handlers ──────────────────────────────────────────────────────────
/**
* Register all tools and handlers on an MCP Server instance.
*
* @param {import('@modelcontextprotocol/sdk/server/index.js').Server} server
* @param {import('@prisma/client').PrismaClient} prisma
*/
export function registerTools(server, prisma) {
// 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 () => {
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 fallbackPromise;
};
// ── List Tools ────────────────────────────────────────────────────────────
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools: toolDefinitions };
});
// ── Call Tools ────────────────────────────────────────────────────────────
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const resolvedUserId = getResolvedUserId();
try {
switch (name) {
// ═══════════════════════════════════════════════════════
// NOTES
// ═══════════════════════════════════════════════════════
case 'create_note': {
const data = {
title: args.title || null,
content: args.content,
color: args.color || 'default',
type: args.type || 'text',
checkItems: args.checkItems ?? null,
labels: args.labels ?? null,
isPinned: args.isPinned || false,
isArchived: args.isArchived || false,
images: args.images ?? null,
links: args.links ?? null,
reminder: args.reminder ? new Date(args.reminder) : null,
isReminderDone: args.isReminderDone || false,
reminderRecurrence: args.reminderRecurrence || null,
reminderLocation: args.reminderLocation || null,
isMarkdown: args.isMarkdown || false,
size: args.size || 'small',
notebookId: args.notebookId || null,
};
if (resolvedUserId) data.userId = resolvedUserId;
else data.userId = await ensureUserId();
const note = await prisma.note.create({ data });
return textResult(parseNote(note));
}
case 'get_notes': {
const where = {};
if (resolvedUserId) where.userId = resolvedUserId;
if (!args?.includeArchived) where.isArchived = false;
if (args?.search) {
where.OR = [
{ title: { contains: args.search } },
{ content: { contains: args.search } },
];
}
if (args?.notebookId) {
where.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
}
const limit = Math.min(args?.limit || DEFAULT_NOTES_LIMIT, 500); // Max 500
const notes = await prisma.note.findMany({
where,
orderBy: [{ isPinned: 'desc' }, { order: 'asc' }, { updatedAt: 'desc' }],
take: limit,
});
const parsed = args?.fullDetails ? notes.map(parseNote) : notes.map(parseNoteLightweight);
return textResult(parsed);
}
case 'get_note': {
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
const note = await prisma.note.findUnique({ where });
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
return textResult(parseNote(note));
}
case 'update_note': {
const updateData = {};
const fields = ['title', 'color', 'type', 'isPinned', 'isArchived', 'isMarkdown', 'size', 'notebookId', 'isReminderDone', 'reminderRecurrence', 'reminderLocation'];
for (const f of fields) {
if (f in args) updateData[f] = args[f];
}
if ('content' in args) updateData.content = args.content;
if ('checkItems' in args) updateData.checkItems = args.checkItems ?? null;
if ('labels' in args) updateData.labels = args.labels ?? null;
if ('images' in args) updateData.images = args.images ?? null;
if ('links' in args) updateData.links = args.links ?? null;
if ('reminder' in args) updateData.reminder = args.reminder ? new Date(args.reminder) : null;
updateData.updatedAt = new Date();
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
const note = await prisma.note.update({ where, data: updateData });
return textResult(parseNote(note));
}
case 'delete_note': {
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
await prisma.note.delete({ where });
return textResult({ success: true, message: 'Note deleted' });
}
case 'search_notes': {
const where = {
isArchived: args.includeArchived || false,
OR: [
{ title: { contains: args.query } },
{ content: { contains: args.query } },
],
};
if (resolvedUserId) where.userId = resolvedUserId;
if (args.notebookId) where.notebookId = args.notebookId;
const notes = await prisma.note.findMany({
where,
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
take: DEFAULT_SEARCH_LIMIT,
});
return textResult(notes.map(parseNoteLightweight));
}
case 'move_note': {
const noteWhere = { id: args.id };
if (resolvedUserId) noteWhere.userId = resolvedUserId;
const targetNotebookId = args.notebookId || null;
// Optimized: Parallel execution
const [note, notebook] = await Promise.all([
prisma.note.update({
where: noteWhere,
data: { notebookId: targetNotebookId, updatedAt: new Date() },
}),
targetNotebookId
? prisma.notebook.findUnique({ where: { id: targetNotebookId }, select: { name: true } })
: Promise.resolve(null),
]);
const notebookName = notebook?.name || 'Inbox';
return textResult({
success: true,
data: { id: note.id, notebookId: note.notebookId, notebook: { name: notebookName } },
message: `Note moved to ${notebookName}`,
});
}
case 'toggle_pin': {
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
const note = await prisma.note.findUnique({ where });
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
const updated = await prisma.note.update({
where,
data: { isPinned: !note.isPinned },
});
return textResult(parseNote(updated));
}
case 'toggle_archive': {
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
const note = await prisma.note.findUnique({ where });
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
const updated = await prisma.note.update({
where,
data: { isArchived: !note.isArchived },
});
return textResult(parseNote(updated));
}
case 'export_notes': {
const noteWhere = {};
const nbWhere = {};
if (resolvedUserId) { noteWhere.userId = resolvedUserId; nbWhere.userId = resolvedUserId; }
// Optimized: Parallel queries
const [notes, notebooks, labels] = await Promise.all([
prisma.note.findMany({
where: noteWhere,
orderBy: { updatedAt: 'desc' },
select: {
id: true,
title: true,
content: true,
color: true,
type: true,
isPinned: true,
isArchived: true,
isMarkdown: true,
size: true,
labels: true,
notebookId: true,
createdAt: true,
updatedAt: true,
},
}),
prisma.notebook.findMany({
where: nbWhere,
include: { _count: { select: { notes: true } } },
orderBy: { order: 'asc' },
}),
prisma.label.findMany({
include: { notebook: { select: { id: true, name: true, userId: true } } },
}),
]);
// Filter labels by userId in memory (faster than multiple queries)
const filteredLabels = resolvedUserId
? labels.filter(l => l.notebook?.userId === resolvedUserId)
: labels;
return textResult({
version: '2.0',
exportDate: new Date().toISOString(),
data: {
notes: notes.map(n => ({
id: n.id,
title: n.title,
content: n.content,
color: n.color,
type: n.type,
isPinned: n.isPinned,
isArchived: n.isArchived,
isMarkdown: n.isMarkdown,
size: n.size,
labels: Array.isArray(n.labels) ? n.labels : [],
notebookId: n.notebookId,
createdAt: n.createdAt,
updatedAt: n.updatedAt,
})),
notebooks: notebooks.map(nb => ({
id: nb.id,
name: nb.name,
icon: nb.icon,
color: nb.color,
noteCount: nb._count.notes,
})),
labels: filteredLabels.map(l => ({
id: l.id,
name: l.name,
color: l.color,
notebookId: l.notebookId,
})),
},
});
}
case 'import_notes': {
const importData = args.data;
let importedNotes = 0, importedLabels = 0, importedNotebooks = 0;
// OPTIMIZED: Batch operations with Promise.all for notebooks
if (importData.data?.notebooks?.length > 0) {
const existingNotebooks = await prisma.notebook.findMany({
where: resolvedUserId ? { userId: resolvedUserId } : {},
select: { name: true },
});
const existingNames = new Set(existingNotebooks.map(nb => nb.name));
const notebooksToCreate = importData.data.notebooks
.filter(nb => !existingNames.has(nb.name))
.map(nb => prisma.notebook.create({
data: {
name: nb.name,
icon: nb.icon || '📁',
color: nb.color || '#3B82F6',
...(resolvedUserId ? { userId: resolvedUserId } : {}),
},
}));
await Promise.all(notebooksToCreate);
importedNotebooks = notebooksToCreate.length;
}
// OPTIMIZED: Batch labels
if (importData.data?.labels?.length > 0) {
const notebooks = await prisma.notebook.findMany({
where: resolvedUserId ? { userId: resolvedUserId } : {},
select: { id: true },
});
const notebookIds = new Set(notebooks.map(nb => nb.id));
const existingLabels = await prisma.label.findMany({
where: { notebookId: { in: Array.from(notebookIds) } },
select: { name: true, notebookId: true },
});
const existingLabelKeys = new Set(existingLabels.map(l => `${l.notebookId}:${l.name}`));
const labelsToCreate = [];
for (const label of importData.data.labels) {
if (label.notebookId && notebookIds.has(label.notebookId)) {
const key = `${label.notebookId}:${label.name}`;
if (!existingLabelKeys.has(key)) {
labelsToCreate.push(prisma.label.create({
data: { name: label.name, color: label.color, notebookId: label.notebookId },
}));
}
}
}
await Promise.all(labelsToCreate);
importedLabels = labelsToCreate.length;
}
// OPTIMIZED: Batch notes with createMany if available, else Promise.all
if (importData.data?.notes?.length > 0) {
const notesData = importData.data.notes.map(note => ({
title: note.title,
content: note.content,
color: note.color || 'default',
type: note.type || 'text',
isPinned: note.isPinned || false,
isArchived: note.isArchived || false,
isMarkdown: note.isMarkdown || false,
size: note.size || 'small',
labels: note.labels ?? null,
notebookId: note.notebookId || null,
...(resolvedUserId ? { userId: resolvedUserId } : {}),
}));
// Try createMany first (faster), fall back to Promise.all
try {
const result = await prisma.note.createMany({
data: notesData,
skipDuplicates: true,
});
importedNotes = result.count;
} catch {
// Fallback to individual creates
const creates = notesData.map(data =>
prisma.note.create({ data }).catch(() => null)
);
const results = await Promise.all(creates);
importedNotes = results.filter(r => r !== null).length;
}
}
return textResult({
success: true,
imported: { notes: importedNotes, labels: importedLabels, notebooks: importedNotebooks },
});
}
// ═══════════════════════════════════════════════════════
// NOTEBOOKS
// ═══════════════════════════════════════════════════════
case 'create_notebook': {
const highestOrder = await prisma.notebook.findFirst({
where: resolvedUserId ? { userId: resolvedUserId } : {},
orderBy: { order: 'desc' },
select: { order: true },
});
const nextOrder = args.order !== undefined ? args.order : (highestOrder?.order ?? -1) + 1;
const data = {
name: args.name.trim(),
icon: args.icon || '📁',
color: args.color || '#3B82F6',
order: nextOrder,
};
data.userId = resolvedUserId || await ensureUserId();
const notebook = await prisma.notebook.create({
data,
include: { labels: true, _count: { select: { notes: true } } },
});
return textResult({ ...notebook, notesCount: notebook._count.notes });
}
case 'get_notebooks': {
const where = {};
if (resolvedUserId) where.userId = resolvedUserId;
const notebooks = await prisma.notebook.findMany({
where,
include: {
labels: { orderBy: { name: 'asc' } },
_count: { select: { notes: true } },
},
orderBy: { order: 'asc' },
});
return textResult(notebooks.map(nb => ({ ...nb, notesCount: nb._count.notes })));
}
case 'get_notebook': {
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
const notebook = await prisma.notebook.findUnique({
where,
include: { labels: true, notes: true, _count: { select: { notes: true } } },
});
if (!notebook) throw new McpError(ErrorCode.InvalidRequest, 'Notebook not found');
return textResult({
...notebook,
notes: notebook.notes.map(parseNoteLightweight),
notesCount: notebook._count.notes,
});
}
case 'update_notebook': {
const updateData = {};
if ('name' in args) updateData.name = args.name.trim();
if ('icon' in args) updateData.icon = args.icon;
if ('color' in args) updateData.color = args.color;
if ('order' in args) updateData.order = args.order;
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
const notebook = await prisma.notebook.update({
where,
data: updateData,
include: { labels: true, _count: { select: { notes: true } } },
});
return textResult({ ...notebook, notesCount: notebook._count.notes });
}
case 'delete_notebook': {
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
// Move notes to inbox before deleting
await prisma.$transaction([
prisma.note.updateMany({
where: { notebookId: args.id, ...(resolvedUserId ? { userId: resolvedUserId } : {}) },
data: { notebookId: null },
}),
prisma.notebook.delete({ where }),
]);
return textResult({ success: true, message: 'Notebook deleted, notes moved to Inbox' });
}
case 'reorder_notebooks': {
const ids = args.notebookIds;
// Optimized: Verify ownership in one query
const where = { id: { in: ids } };
if (resolvedUserId) where.userId = resolvedUserId;
const existingNotebooks = await prisma.notebook.findMany({
where,
select: { id: true },
});
const existingIds = new Set(existingNotebooks.map(nb => nb.id));
const missingIds = ids.filter(id => !existingIds.has(id));
if (missingIds.length > 0) {
throw new McpError(ErrorCode.InvalidRequest, `Notebooks not found: ${missingIds.join(', ')}`);
}
await prisma.$transaction(
ids.map((id, index) =>
prisma.notebook.update({ where: { id }, data: { order: index } })
)
);
return textResult({ success: true, message: 'Notebooks reordered' });
}
// ═══════════════════════════════════════════════════════
// LABELS - OPTIMIZED to fix N+1 query
// ═══════════════════════════════════════════════════════
case 'create_label': {
const existing = await prisma.label.findFirst({
where: { name: args.name.trim(), notebookId: args.notebookId },
});
if (existing) throw new McpError(ErrorCode.InvalidRequest, 'Label already exists in this notebook');
const label = await prisma.label.create({
data: {
name: args.name.trim(),
color: args.color || LABEL_COLORS[Math.floor(Math.random() * LABEL_COLORS.length)],
notebookId: args.notebookId,
},
});
return textResult(label);
}
case 'get_labels': {
const where = {};
if (args?.notebookId) where.notebookId = args.notebookId;
// OPTIMIZED: Single query with include, then filter in memory
const labels = await prisma.label.findMany({
where,
include: { notebook: { select: { id: true, name: true, userId: true } } },
orderBy: { name: 'asc' },
});
// Filter by userId in memory (much faster than N+1 queries)
let filteredLabels = labels;
if (resolvedUserId) {
filteredLabels = labels.filter(l => l.notebook?.userId === resolvedUserId);
}
return textResult(filteredLabels);
}
case 'update_label': {
const updateData = {};
if ('name' in args) updateData.name = args.name.trim();
if ('color' in args) updateData.color = args.color;
const label = await prisma.label.update({
where: { id: args.id },
data: updateData,
});
return textResult(label);
}
case 'delete_label': {
await prisma.label.delete({ where: { id: args.id } });
return textResult({ success: true, message: 'Label deleted' });
}
// ═══════════════════════════════════════════════════════
// REMINDERS
// ═══════════════════════════════════════════════════════
case 'get_due_reminders': {
const where = {
reminder: { not: null, lte: new Date() },
isReminderDone: false,
isArchived: false,
};
if (resolvedUserId) where.userId = resolvedUserId;
const reminders = await prisma.note.findMany({
where,
select: { id: true, title: true, content: true, reminder: true },
orderBy: { reminder: 'asc' },
});
// Mark them as done
if (reminders.length > 0) {
await prisma.note.updateMany({
where: { id: { in: reminders.map(r => r.id) } },
data: { isReminderDone: true },
});
}
return textResult({ success: true, count: reminders.length, reminders });
}
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
}
} catch (error) {
if (error instanceof McpError) throw error;
throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${error.message}`);
}
});
}