/** * Memento MCP Server - Tool Definitions & Handlers * * Fast, minimal overhead. All queries filter trashed notes. * Compact JSON output. Direct DB lookups. */ import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import { requestContext } from './request-context.js'; const DEFAULT_SEARCH_LIMIT = 50; 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'; 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) }], }; } // ─── Tool Definitions ────────────────────────────────────────────────────── const toolDefinitions = [ // ═══ NOTES ═══ { name: 'create_note', description: 'Create a new note. Set content (required), optional title, color, labels, notebook assignment, reminder, or checklist items.', inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'Note title' }, content: { type: 'string', description: 'Note body text (required)' }, color: { type: 'string', description: `Color: ${NOTE_COLORS}`, default: 'default' }, type: { type: 'string', enum: ['text', 'markdown', 'richtext', 'checklist'], description: 'Note type. "text" = plain text, "markdown" = Markdown rendered, "richtext" = rich text editor (default), "checklist" = interactive checklist', default: 'richtext', }, checkItems: { type: 'array', description: 'Checklist items (when type=checklist)', items: { type: 'object', properties: { id: { type: 'string' }, text: { type: 'string' }, checked: { type: 'boolean' } }, required: ['id', 'text', 'checked'], }, }, labels: { type: 'array', description: 'Tags/labels', items: { type: 'string' } }, isPinned: { type: 'boolean', description: 'Pin to top', default: false }, isArchived: { type: 'boolean', description: 'Create as archived', default: false }, images: { type: 'array', description: 'Image URLs', items: { type: 'string' } }, links: { type: 'array', description: 'Attached URLs', items: { type: 'string' } }, reminder: { type: 'string', description: 'Reminder datetime (ISO 8601)' }, isReminderDone: { type: 'boolean', default: false }, reminderRecurrence: { type: 'string', description: 'daily, weekly, monthly, yearly' }, reminderLocation: { type: 'string', description: 'Location string' }, isMarkdown: { type: 'boolean', description: '(Deprecated — use type="markdown" instead) Render as markdown', default: false }, size: { type: 'string', enum: ['small', 'medium', 'large'], default: 'small' }, notebookId: { type: 'string', description: 'Assign to notebook' }, }, required: ['content'], }, }, { name: 'get_notes', description: 'List notes. Returns lightweight format by default (truncated content, no images). Use fullDetails=true for full payloads.', inputSchema: { type: 'object', properties: { includeArchived: { type: 'boolean', description: 'Include archived notes', default: false }, search: { type: 'string', description: 'Keyword filter on title/content' }, notebookId: { type: 'string', description: 'Filter by notebook. Use "inbox" for unfiled notes.' }, fullDetails: { type: 'boolean', description: 'Full payload including images', default: false }, limit: { type: 'number', description: `Max results (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 a note. Only fields you include will be changed.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Note ID' }, title: { type: 'string' }, content: { type: 'string' }, color: { type: 'string', description: `One of: ${NOTE_COLORS}` }, type: { type: 'string', enum: ['text', 'markdown', 'richtext', 'checklist'], description: 'Note type. "text" = plain text, "markdown" = Markdown rendered, "richtext" = rich text editor, "checklist" = interactive 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', description: '(Deprecated — use type="markdown" instead)' }, size: { type: 'string', enum: ['small', 'medium', 'large'] }, notebookId: { type: 'string' }, }, required: ['id'], }, }, { name: 'delete_note', description: 'Permanently delete a note.', 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 to a notebook' }, includeArchived: { type: 'boolean', default: false }, }, required: ['query'], }, }, { name: 'move_note', description: 'Move a note to a notebook. Set notebookId to null to move to Inbox.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Note ID' }, notebookId: { type: 'string', description: 'Target notebook ID, or null 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, notebooks, and labels as JSON.', inputSchema: { type: 'object', properties: {} }, }, { name: 'import_notes', description: 'Import notes, notebooks, and labels from a previous export. Duplicates are skipped.', inputSchema: { type: 'object', properties: { data: { type: 'object', description: 'Export JSON (from export_notes)', properties: { version: { type: 'string' }, data: { type: 'object', properties: { notes: { type: 'array' }, labels: { type: 'array' }, notebooks: { type: 'array' }, }, }, }, }, }, required: ['data'], }, }, // ═══ NOTEBOOKS ═══ { name: 'create_notebook', description: 'Create a notebook with a name, icon, and color.', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Notebook name' }, icon: { type: 'string', description: 'Icon (emoji)', default: '📁' }, color: { type: 'string', description: 'Hex color', default: '#3B82F6' }, order: { type: 'number', description: 'Sort position (auto if omitted)' }, }, required: ['name'], }, }, { name: 'get_notebooks', description: 'List 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 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 are moved to Inbox.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Notebook ID' } }, required: ['id'], }, }, { name: 'reorder_notebooks', description: 'Set the order of all notebooks by passing their IDs in sequence.', inputSchema: { type: 'object', properties: { notebookIds: { type: 'array', description: 'Notebook IDs in desired order', items: { type: 'string' }, }, }, required: ['notebookIds'], }, }, // ═══ LABELS ═══ { name: 'create_label', description: 'Create a label inside a notebook.', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Label name' }, color: { type: 'string', description: `Color: ${LABEL_COLORS}` }, notebookId: { type: 'string', description: 'Parent notebook ID' }, }, required: ['name', 'notebookId'], }, }, { name: 'get_labels', description: 'List labels, optionally filtered by notebook.', inputSchema: { type: 'object', properties: { notebookId: { type: 'string', description: 'Filter by notebook' }, }, }, }, { name: 'update_label', description: 'Update a label 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.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Label ID' } }, required: ['id'], }, }, // ═══ REMINDERS ═══ { name: 'get_due_reminders', description: 'Get notes with due reminders. Designed for cron/automation.', inputSchema: { type: 'object', properties: {} }, }, ]; // ─── Tool Handlers ────────────────────────────────────────────────────────── export function registerTools(server, prisma) { const getResolvedUserId = () => { const store = requestContext.getStore(); return store?.userId || null; }; 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; }; const noteWhere = (resolvedUserId, extra = {}) => ({ trashedAt: null, ...(resolvedUserId ? { userId: resolvedUserId } : {}), ...extra, }); server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: toolDefinitions }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; const uid = 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, userId: uid || await ensureUserId(), }; const note = await prisma.note.create({ data }); return textResult(parseNote(note)); } case 'get_notes': { const extra = {}; if (!args?.includeArchived) extra.isArchived = false; if (args?.search) { extra.OR = [ { title: { contains: args.search } }, { content: { contains: args.search } }, ]; } if (args?.notebookId) { extra.notebookId = args.notebookId === 'inbox' ? null : args.notebookId; } const limit = Math.min(args?.limit || DEFAULT_NOTES_LIMIT, MAX_NOTES_LIMIT); const notes = await prisma.note.findMany({ where: noteWhere(uid, extra), 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 note = await prisma.note.findUnique({ where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null }, }); if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found'); return textResult(parseNote(note)); } case 'update_note': { const d = {}; const simpleFields = ['title', 'color', 'type', 'isPinned', 'isArchived', 'isMarkdown', 'size', 'notebookId', 'isReminderDone', 'reminderRecurrence', 'reminderLocation']; for (const f of simpleFields) { if (f in args) d[f] = args[f]; } if ('content' in args) d.content = args.content; if ('checkItems' in args) d.checkItems = args.checkItems ?? null; if ('labels' in args) d.labels = args.labels ?? null; if ('images' in args) d.images = args.images ?? null; if ('links' in args) d.links = args.links ?? null; if ('reminder' in args) d.reminder = args.reminder ? new Date(args.reminder) : null; d.updatedAt = new Date(); const note = await prisma.note.update({ where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null }, data: d, }); return textResult(parseNote(note)); } case 'delete_note': { await prisma.note.delete({ where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null }, }); return textResult({ success: true, deleted: args.id }); } case 'search_notes': { const where = noteWhere(uid, { isArchived: args.includeArchived || false, OR: [ { title: { contains: args.query } }, { content: { contains: args.query } }, ], ...(args.notebookId ? { 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 targetId = args.notebookId || null; const [note, notebook] = await Promise.all([ prisma.note.update({ where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null }, data: { notebookId: targetId, updatedAt: new Date() }, }), targetId ? prisma.notebook.findUnique({ where: { id: targetId }, select: { name: true } }) : Promise.resolve(null), ]); return textResult({ success: true, id: note.id, notebookId: note.notebookId, notebookName: notebook?.name || 'Inbox', }); } case 'toggle_pin': { const note = await prisma.note.findUnique({ where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null }, }); if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found'); const updated = await prisma.note.update({ where: { id: args.id }, data: { isPinned: !note.isPinned }, }); return textResult(parseNote(updated)); } case 'toggle_archive': { const note = await prisma.note.findUnique({ where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null }, }); if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found'); const updated = await prisma.note.update({ where: { id: args.id }, data: { isArchived: !note.isArchived }, }); return textResult(parseNote(updated)); } case 'export_notes': { const nbWhere = uid ? { userId: uid } : {}; const [notes, notebooks, labels] = await Promise.all([ prisma.note.findMany({ where: noteWhere(uid), 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 } } }, }), ]); const filteredLabels = uid ? labels.filter(l => l.notebook?.userId === uid) : 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; if (importData.data?.notebooks?.length > 0) { const existing = await prisma.notebook.findMany({ where: uid ? { userId: uid } : {}, select: { name: true }, }); const existingNames = new Set(existing.map(nb => nb.name)); const toCreate = importData.data.notebooks .filter(nb => !existingNames.has(nb.name)) .map(nb => ({ name: nb.name, icon: nb.icon || '📁', color: nb.color || '#3B82F6', ...(uid ? { userId: uid } : {}), })); if (toCreate.length > 0) { await prisma.notebook.createMany({ data: toCreate, skipDuplicates: true }); importedNotebooks = toCreate.length; } } if (importData.data?.labels?.length > 0) { const notebooks = await prisma.notebook.findMany({ where: uid ? { userId: uid } : {}, select: { id: true }, }); const nbIds = new Set(notebooks.map(nb => nb.id)); const existing = await prisma.label.findMany({ where: { notebookId: { in: Array.from(nbIds) } }, select: { name: true, notebookId: true }, }); const existingKeys = new Set(existing.map(l => `${l.notebookId}:${l.name}`)); const toCreate = importData.data.labels .filter(l => l.notebookId && nbIds.has(l.notebookId) && !existingKeys.has(`${l.notebookId}:${l.name}`)) .map(l => ({ name: l.name, color: l.color, notebookId: l.notebookId })); if (toCreate.length > 0) { await prisma.label.createMany({ data: toCreate, skipDuplicates: true }); importedLabels = toCreate.length; } } 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, ...(uid ? { userId: uid } : {}), })); try { const result = await prisma.note.createMany({ data: notesData, skipDuplicates: true }); importedNotes = result.count; } catch { const results = await Promise.all(notesData.map(d => prisma.note.create({ data: d }).catch(() => null))); importedNotes = results.filter(r => r !== null).length; } } return textResult({ success: true, imported: { notes: importedNotes, labels: importedLabels, notebooks: importedNotebooks }, }); } // ═══ NOTEBOOKS ═══ case 'create_notebook': { const highest = await prisma.notebook.findFirst({ where: uid ? { userId: uid } : {}, orderBy: { order: 'desc' }, select: { order: true }, }); const nextOrder = args.order !== undefined ? args.order : (highest?.order ?? -1) + 1; const notebook = await prisma.notebook.create({ data: { name: args.name.trim(), icon: args.icon || '📁', color: args.color || '#3B82F6', order: nextOrder, userId: uid || await ensureUserId(), }, include: { labels: true, _count: { select: { notes: true } } }, }); return textResult({ ...notebook, notesCount: notebook._count.notes }); } case 'get_notebooks': { const where = uid ? { userId: uid } : {}; 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, ...(uid ? { userId: uid } : {}) }; const notebook = await prisma.notebook.findUnique({ where, include: { labels: true, notes: { where: { trashedAt: null } }, _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 d = {}; if ('name' in args) d.name = args.name.trim(); if ('icon' in args) d.icon = args.icon; if ('color' in args) d.color = args.color; if ('order' in args) d.order = args.order; const where = { id: args.id, ...(uid ? { userId: uid } : {}) }; const notebook = await prisma.notebook.update({ where, data: d, include: { labels: true, _count: { select: { notes: true } } }, }); return textResult({ ...notebook, notesCount: notebook._count.notes }); } case 'delete_notebook': { await prisma.$transaction([ prisma.note.updateMany({ where: { notebookId: args.id, ...(uid ? { userId: uid } : {}) }, data: { notebookId: null }, }), prisma.notebook.delete({ where: { id: args.id, ...(uid ? { userId: uid } : {}) }, }), ]); return textResult({ success: true, message: 'Notebook deleted, notes moved to Inbox' }); } case 'reorder_notebooks': { const ids = args.notebookIds; const where = { id: { in: ids }, ...(uid ? { userId: uid } : {}) }; const existing = await prisma.notebook.findMany({ where, select: { id: true } }); const existingIds = new Set(existing.map(nb => nb.id)); const missing = ids.filter(id => !existingIds.has(id)); if (missing.length > 0) { throw new McpError(ErrorCode.InvalidRequest, `Notebooks not found: ${missing.join(', ')}`); } await prisma.$transaction( ids.map((id, index) => prisma.notebook.update({ where: { id }, data: { order: index } }) ) ); return textResult({ success: true }); } // ═══ 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; const labels = await prisma.label.findMany({ where, include: { notebook: { select: { id: true, name: true, userId: true } } }, orderBy: { name: 'asc' }, }); const filtered = uid ? labels.filter(l => l.notebook?.userId === uid) : labels; return textResult(filtered); } case 'update_label': { const d = {}; if ('name' in args) d.name = args.name.trim(); if ('color' in args) d.color = args.color; const label = await prisma.label.update({ where: { id: args.id }, data: d }); return textResult(label); } case 'delete_label': { await prisma.label.delete({ where: { id: args.id } }); return textResult({ success: true }); } // ═══ REMINDERS ═══ case 'get_due_reminders': { const where = noteWhere(uid, { reminder: { not: null, lte: new Date() }, isReminderDone: false, isArchived: false, }); const reminders = await prisma.note.findMany({ where, select: { id: true, title: true, content: true, reminder: true }, orderBy: { reminder: 'asc' }, }); if (reminders.length > 0) { await prisma.note.updateMany({ where: { id: { in: reminders.map(r => r.id) } }, data: { isReminderDone: true }, }); } return textResult({ 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 error: ${error.message}`); } }); }