/** * Memento MCP Server - Shared Tool Definitions & Handlers * * All tool definitions and their handler logic are centralized here. * Both stdio (index.js) and HTTP (index-sse.js) transports use this module. */ // PrismaClient is injected via registerTools() — no direct import needed here. import { generateApiKey, listApiKeys, revokeApiKey, resolveUser, validateApiKey } from './auth.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; // ─── Helpers ──────────────────────────────────────────────────────────────── export function parseNote(dbNote) { return { ...dbNote, checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null, labels: dbNote.labels ? JSON.parse(dbNote.labels) : null, images: dbNote.images ? JSON.parse(dbNote.images) : null, links: dbNote.links ? JSON.parse(dbNote.links) : null, }; } export function parseNoteLightweight(dbNote) { 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: !!dbNote.images, imageCount: dbNote.images ? JSON.parse(dbNote.images).length : 0, labels: dbNote.labels ? JSON.parse(dbNote.labels) : null, hasCheckItems: !!dbNote.checkItems, checkItemsCount: dbNote.checkItems ? JSON.parse(dbNote.checkItems).length : 0, 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 100)', default: 100 }, }, }, }, { 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: 'delete_all_notes', description: 'Delete ALL notes, labels, and notebooks for the configured user. Use with caution.', inputSchema: { type: 'object', properties: {} }, }, { 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'], }, }, // ═══════════════════════════════════════════════════════════ // AI TOOLS // ═══════════════════════════════════════════════════════════ { name: 'generate_title_suggestions', description: 'Use AI to generate 3 title suggestions for a note based on its content.', inputSchema: { type: 'object', properties: { content: { type: 'string', description: 'Note content (at least 10 words recommended)' }, }, required: ['content'], }, }, { name: 'reformulate_text', description: 'Use AI to reformulate text: clarify meaning, shorten, or improve writing style.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to reformulate' }, option: { type: 'string', enum: ['clarify', 'shorten', 'improve'], description: 'Reformulation mode' }, }, required: ['text', 'option'], }, }, { name: 'generate_tags', description: 'Use AI to generate tags/labels for content. Optionally contextual to a notebook\'s existing labels.', inputSchema: { type: 'object', properties: { content: { type: 'string', description: 'Content to analyze' }, notebookId: { type: 'string', description: 'Notebook ID for contextual tagging (uses existing labels)' }, language: { type: 'string', description: 'Language hint (e.g. "en", "fr")', default: 'en' }, }, required: ['content'], }, }, { name: 'suggest_notebook', description: 'Use AI to suggest which notebook a note should belong to based on its content.', inputSchema: { type: 'object', properties: { noteContent: { type: 'string', description: 'Note content (at least 20 words recommended)' }, language: { type: 'string', default: 'en' }, }, required: ['noteContent'], }, }, { name: 'get_notebook_summary', description: 'Generate an AI summary of all notes in a notebook.', inputSchema: { type: 'object', properties: { notebookId: { type: 'string', description: 'Notebook ID' }, language: { type: 'string', default: 'en' }, }, required: ['notebookId'], }, }, { name: 'get_memory_echo', description: 'Get the next Memory Echo insight — AI-discovered connections between your notes based on semantic similarity.', inputSchema: { type: 'object', properties: {} }, }, { name: 'get_note_connections', description: 'Get all semantically related notes (Memory Echo connections) for a specific note. Supports pagination.', inputSchema: { type: 'object', properties: { noteId: { type: 'string', description: 'Note ID' }, page: { type: 'number', description: 'Page number (default 1)', default: 1 }, limit: { type: 'number', description: 'Results per page (default 10, max 50)', default: 10 }, }, required: ['noteId'], }, }, { name: 'dismiss_connection', description: 'Dismiss a Memory Echo connection between two notes.', inputSchema: { type: 'object', properties: { noteId: { type: 'string', description: 'First note ID' }, connectedNoteId: { type: 'string', description: 'Connected note ID to dismiss' }, }, required: ['noteId', 'connectedNoteId'], }, }, { name: 'fuse_notes', description: 'Use AI to merge/fuse multiple notes into a single unified note.', inputSchema: { type: 'object', properties: { noteIds: { type: 'array', description: 'Array of note IDs to fuse (minimum 2)', items: { type: 'string' }, }, prompt: { type: 'string', description: 'Optional instructions for the fusion' }, }, required: ['noteIds'], }, }, { name: 'batch_organize', description: 'Create an AI-powered organization plan to move inbox notes into appropriate notebooks, or apply a previously created plan.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['create_plan', 'apply_plan'], description: 'Create a plan or apply it' }, language: { type: 'string', default: 'en' }, plan: { type: 'object', description: 'The plan object (required for apply_plan)' }, selectedNoteIds: { type: 'array', items: { type: 'string' }, description: 'Note IDs to move (required for apply_plan)' }, }, required: ['action'], }, }, { name: 'suggest_auto_labels', description: 'Use AI to suggest new labels for a notebook based on its notes\' content (requires 15+ notes), or create selected labels.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['suggest', 'create'], description: 'Suggest labels or create selected ones' }, notebookId: { type: 'string', description: 'Notebook ID' }, language: { type: 'string', default: 'en' }, selectedLabels: { type: 'array', items: { type: 'string' }, description: 'Label names to create (for create action)' }, suggestions: { type: 'object', description: 'Suggestions object from suggest step (for create action)' }, }, required: ['action', 'notebookId'], }, }, // ═══════════════════════════════════════════════════════════ // 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: {} }, }, // ═══════════════════════════════════════════════════════════ // API KEY MANAGEMENT // ═══════════════════════════════════════════════════════════ { name: 'generate_api_key', description: 'Generate a new API key for a user. The raw key is only shown once — store it securely.', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Human-readable name for this key (e.g. "N8N Automation")' }, userId: { type: 'string', description: 'User ID to link this key to. If omitted, uses the default user.' }, userEmail: { type: 'string', description: 'Alternatively, identify the user by email.' }, }, }, }, { name: 'list_api_keys', description: 'List all API keys (without revealing the actual keys). Can filter by user.', inputSchema: { type: 'object', properties: { userId: { type: 'string', description: 'Filter keys by user ID' }, }, }, }, { name: 'revoke_api_key', description: 'Revoke an API key by its short ID. The key will no longer be usable.', inputSchema: { type: 'object', properties: { shortId: { type: 'string', description: 'The short ID of the key (e.g. "a1b2c3d4")' }, }, required: ['shortId'], }, }, ]; // ─── 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 * @param {object} [options] * @param {string} [options.userId] - Optional user ID for multi-user filtering * @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; // Resolve userId: if not provided, auto-detect the first user let resolvedUserId = userId; const ensureUserId = async () => { if (!resolvedUserId) { const firstUser = await prisma.user.findFirst({ select: { id: true } }); if (firstUser) resolvedUserId = firstUser.id; } return resolvedUserId; }; // ── List Tools ──────────────────────────────────────────────────────────── server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: toolDefinitions }; }); // ── Call Tools ──────────────────────────────────────────────────────────── server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; 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 ? JSON.stringify(args.checkItems) : null, labels: args.labels ? JSON.stringify(args.labels) : null, isPinned: args.isPinned || false, isArchived: args.isArchived || false, images: args.images ? JSON.stringify(args.images) : null, links: args.links ? JSON.stringify(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 = args?.limit || 100; 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 ? JSON.stringify(args.checkItems) : null; if ('labels' in args) updateData.labels = args.labels ? JSON.stringify(args.labels) : null; if ('images' in args) updateData.images = args.images ? JSON.stringify(args.images) : null; if ('links' in args) updateData.links = args.links ? JSON.stringify(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 'delete_all_notes': { const where = {}; if (resolvedUserId) where.userId = resolvedUserId; const count = await prisma.note.deleteMany({ where }); if (resolvedUserId) { await prisma.label.deleteMany({ where: { notebook: { userId: resolvedUserId } } }); await prisma.notebook.deleteMany({ where: { userId: resolvedUserId } }); } else { await prisma.label.deleteMany({}); await prisma.notebook.deleteMany({}); } return textResult({ success: true, deletedNotes: count.count }); } 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: 50, }); return textResult(notes.map(parseNoteLightweight)); } case 'move_note': { const noteWhere = { id: args.id }; if (resolvedUserId) noteWhere.userId = resolvedUserId; const targetNotebookId = args.notebookId || null; const note = await prisma.note.update({ where: noteWhere, data: { notebookId: targetNotebookId, updatedAt: new Date() }, }); let notebookName = 'Inbox'; if (targetNotebookId) { const nb = await prisma.notebook.findUnique({ where: { id: targetNotebookId } }); if (nb) notebookName = nb.name; } 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; } const notes = await prisma.note.findMany({ where: noteWhere, orderBy: { updatedAt: 'desc' } }); const notebooks = await prisma.notebook.findMany({ where: nbWhere, include: { _count: { select: { notes: true } } }, orderBy: { order: 'asc' }, }); const labels = await prisma.label.findMany({ where: nbWhere.notebookId ? {} : {}, include: { notebook: { select: { id: true, name: true } } }, }); // If userId filtering, filter labels by user's notebooks const filteredLabels = userId ? labels.filter(l => l.notebook && l.notebook.userId === userId) : 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: n.labels ? JSON.parse(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; // Import notebooks if (importData.data?.notebooks) { for (const nb of importData.data.notebooks) { const existing = userId ? await prisma.notebook.findFirst({ where: { name: nb.name, userId: resolvedUserId } }) : await prisma.notebook.findFirst({ where: { name: nb.name } }); if (!existing) { await prisma.notebook.create({ data: { name: nb.name, icon: nb.icon || '📁', color: nb.color || '#3B82F6', ...(resolvedUserId ? { userId: resolvedUserId } : {}), }, }); importedNotebooks++; } } } // Import labels if (importData.data?.labels) { for (const label of importData.data.labels) { const nbWhere2 = { name: label.notebookId }; // We need to find notebook by ID const notebook = label.notebookId ? await prisma.notebook.findUnique({ where: { id: label.notebookId } }) : null; if (notebook) { const existing = await prisma.label.findFirst({ where: { name: label.name, notebookId: notebook.id }, }); if (!existing) { await prisma.label.create({ data: { name: label.name, color: label.color, notebookId: notebook.id }, }); importedLabels++; } } } } // Import notes if (importData.data?.notes) { for (const note of importData.data.notes) { await prisma.note.create({ data: { 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 ? JSON.stringify(note.labels) : null, notebookId: note.notebookId || null, ...(resolvedUserId ? { userId: resolvedUserId } : {}), }, }); importedNotes++; } } 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.note.updateMany({ where: { notebookId: args.id, ...(resolvedUserId ? { userId: resolvedUserId } : {}) }, data: { notebookId: null }, }); await prisma.notebook.delete({ where }); return textResult({ success: true, message: 'Notebook deleted, notes moved to Inbox' }); } case 'reorder_notebooks': { const ids = args.notebookIds; // Verify ownership for (const id of ids) { const where = { id }; if (resolvedUserId) where.userId = resolvedUserId; const nb = await prisma.notebook.findUnique({ where }); if (!nb) throw new McpError(ErrorCode.InvalidRequest, `Notebook ${id} not found`); } await prisma.$transaction( ids.map((id, index) => prisma.notebook.update({ where: { id }, data: { order: index } }) ) ); return textResult({ success: true, message: 'Notebooks reordered' }); } // ═══════════════════════════════════════════════════════ // LABELS // ═══════════════════════════════════════════════════════ 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; let labels = await prisma.label.findMany({ where, include: { notebook: { select: { id: true, name: true } } }, orderBy: { name: 'asc' }, }); // Filter by userId if set if (resolvedUserId) { const userNbIds = (await prisma.notebook.findMany({ where: { userId: resolvedUserId }, select: { id: true }, })).map(nb => nb.id); labels = labels.filter(l => userNbIds.includes(l.notebookId)); } return textResult(labels); } 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' }); } // ═══════════════════════════════════════════════════════ // AI TOOLS (direct database / API calls) // ═══════════════════════════════════════════════════════ case 'generate_title_suggestions': { if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features'); const resp = await fetch(`${appBaseUrl}/api/ai/title-suggestions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: args.content }), }); const data = await resp.json(); if (!resp.ok) throw new McpError(ErrorCode.InternalError, data.error || 'Title suggestion failed'); return textResult(data); } case 'reformulate_text': { if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features'); const resp = await fetch(`${appBaseUrl}/api/ai/reformulate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: args.text, option: args.option }), }); const data = await resp.json(); if (!resp.ok) throw new McpError(ErrorCode.InternalError, data.error || 'Reformulation failed'); return textResult(data); } case 'generate_tags': { if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features'); const resp = await fetch(`${appBaseUrl}/api/ai/tags`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: args.content, notebookId: args.notebookId, language: args.language || 'en' }), }); const data = await resp.json(); if (!resp.ok) throw new McpError(ErrorCode.InternalError, data.error || 'Tag generation failed'); return textResult(data); } case 'suggest_notebook': { if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features'); const resp = await fetch(`${appBaseUrl}/api/ai/suggest-notebook`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ noteContent: args.noteContent, language: args.language || 'en' }), }); const data = await resp.json(); if (!resp.ok) throw new McpError(ErrorCode.InternalError, data.error || 'Notebook suggestion failed'); return textResult(data); } case 'get_notebook_summary': { if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features'); const resp = await fetch(`${appBaseUrl}/api/ai/notebook-summary`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ notebookId: args.notebookId, language: args.language || 'en' }), }); const data = await resp.json(); if (!resp.ok) throw new McpError(ErrorCode.InternalError, data.error || 'Summary generation failed'); return textResult(data); } case 'get_memory_echo': { if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features'); const resp = await fetch(`${appBaseUrl}/api/ai/echo`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, }); const data = await resp.json(); return textResult(data); } case 'get_note_connections': { if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features'); const params = new URLSearchParams({ noteId: args.noteId, page: String(args.page || 1), limit: String(Math.min(args.limit || 10, 50)) }); const resp = await fetch(`${appBaseUrl}/api/ai/echo/connections?${params}`); const data = await resp.json(); if (!resp.ok) throw new McpError(ErrorCode.InternalError, data.error || 'Connection fetch failed'); return textResult(data); } case 'dismiss_connection': { if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features'); const resp = await fetch(`${appBaseUrl}/api/ai/echo/dismiss`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ noteId: args.noteId, connectedNoteId: args.connectedNoteId }), }); const data = await resp.json(); if (!resp.ok) throw new McpError(ErrorCode.InternalError, data.error || 'Dismiss failed'); return textResult(data); } case 'fuse_notes': { if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features'); if (!args.noteIds || args.noteIds.length < 2) { throw new McpError(ErrorCode.InvalidRequest, 'At least 2 note IDs required'); } const resp = await fetch(`${appBaseUrl}/api/ai/echo/fusion`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ noteIds: args.noteIds, prompt: args.prompt }), }); const data = await resp.json(); if (!resp.ok) throw new McpError(ErrorCode.InternalError, data.error || 'Fusion failed'); return textResult(data); } case 'batch_organize': { if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features'); if (args.action === 'create_plan') { const resp = await fetch(`${appBaseUrl}/api/ai/batch-organize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ language: args.language || 'en' }), }); const data = await resp.json(); if (!resp.ok) throw new McpError(ErrorCode.InternalError, data.error || 'Plan creation failed'); return textResult(data); } else if (args.action === 'apply_plan') { const resp = await fetch(`${appBaseUrl}/api/ai/batch-organize`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ plan: args.plan, selectedNoteIds: args.selectedNoteIds }), }); const data = await resp.json(); if (!resp.ok) throw new McpError(ErrorCode.InternalError, data.error || 'Plan application failed'); return textResult(data); } else { throw new McpError(ErrorCode.InvalidRequest, 'Invalid action. Use "create_plan" or "apply_plan"'); } } case 'suggest_auto_labels': { if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features'); if (args.action === 'suggest') { const resp = await fetch(`${appBaseUrl}/api/ai/auto-labels`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ notebookId: args.notebookId, language: args.language || 'en' }), }); const data = await resp.json(); if (!resp.ok) throw new McpError(ErrorCode.InternalError, data.error || 'Label suggestion failed'); return textResult(data); } else if (args.action === 'create') { const resp = await fetch(`${appBaseUrl}/api/ai/auto-labels`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ suggestions: args.suggestions, selectedLabels: args.selectedLabels }), }); const data = await resp.json(); if (!resp.ok) throw new McpError(ErrorCode.InternalError, data.error || 'Label creation failed'); return textResult(data); } else { throw new McpError(ErrorCode.InvalidRequest, 'Invalid action. Use "suggest" or "create"'); } } // ═══════════════════════════════════════════════════════ // 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 }); } // ═══════════════════════════════════════════════════════ // API KEY MANAGEMENT // ═══════════════════════════════════════════════════════ case 'generate_api_key': { // Resolve target user let targetUserId = args?.userId || resolvedUserId; if (!targetUserId && args?.userEmail) { const user = await resolveUser(prisma, args.userEmail); if (user) targetUserId = user.id; } if (!targetUserId) targetUserId = await ensureUserId(); const result = await generateApiKey(prisma, { name: args?.name, userId: targetUserId, }); return textResult({ warning: 'Store this raw key securely — it will NOT be shown again.', rawKey: result.rawKey, info: result.info, }); } case 'list_api_keys': { const keys = await listApiKeys(prisma, { userId: args?.userId }); return textResult(keys); } case 'revoke_api_key': { const revoked = await revokeApiKey(prisma, args.shortId); if (!revoked) throw new McpError(ErrorCode.InvalidRequest, 'Key not found or already revoked'); return textResult({ success: true, message: `Key ${args.shortId} revoked` }); } 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}`); } }); }