All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 16s
958 lines
34 KiB
JavaScript
958 lines
34 KiB
JavaScript
/**
|
|
* 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: 'batch_move_notes',
|
|
description: 'Move multiple notes to a notebook simultaneously.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
ids: { type: 'array', items: { type: 'string' }, description: 'Array of Note IDs' },
|
|
notebookId: { type: 'string', description: 'Target notebook ID, or null for Inbox' },
|
|
},
|
|
required: ['ids'],
|
|
},
|
|
},
|
|
{
|
|
name: 'batch_delete_notes',
|
|
description: 'Permanently delete multiple notes.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
ids: { type: 'array', items: { type: 'string' }, description: 'Array of Note IDs' },
|
|
},
|
|
required: ['ids'],
|
|
},
|
|
},
|
|
{
|
|
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)' },
|
|
parentId: { type: 'string', description: 'Parent notebook ID for sub-notebooks' },
|
|
},
|
|
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' },
|
|
parentId: { type: 'string', description: 'Parent notebook ID (set to null to move to root)' },
|
|
},
|
|
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'],
|
|
},
|
|
},
|
|
{
|
|
name: 'get_notebook_hierarchy',
|
|
description: 'Get a nested tree structure of all notebooks (parents and children).',
|
|
inputSchema: { type: 'object', properties: {} },
|
|
},
|
|
|
|
// ═══ 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 'batch_move_notes': {
|
|
const targetId = args.notebookId || null;
|
|
const ids = args.ids || [];
|
|
|
|
await prisma.note.updateMany({
|
|
where: { id: { in: ids }, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
|
data: { notebookId: targetId, updatedAt: new Date() },
|
|
});
|
|
|
|
return textResult({ success: true, count: ids.length, notebookId: targetId });
|
|
}
|
|
|
|
case 'batch_delete_notes': {
|
|
const ids = args.ids || [];
|
|
await prisma.note.deleteMany({
|
|
where: { id: { in: ids }, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
|
});
|
|
return textResult({ success: true, count: ids.length });
|
|
}
|
|
|
|
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,
|
|
parentId: args.parentId || null,
|
|
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;
|
|
if ('parentId' in args) d.parentId = args.parentId;
|
|
|
|
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 });
|
|
}
|
|
|
|
case 'get_notebook_hierarchy': {
|
|
const where = uid ? { userId: uid } : {};
|
|
const notebooks = await prisma.notebook.findMany({
|
|
where,
|
|
include: {
|
|
_count: { select: { notes: true } },
|
|
},
|
|
orderBy: { order: 'asc' },
|
|
});
|
|
|
|
// Build tree
|
|
const nbMap = new Map();
|
|
notebooks.forEach(nb => nbMap.set(nb.id, { ...nb, notesCount: nb._count.notes, children: [] }));
|
|
|
|
const root = [];
|
|
nbMap.forEach(nb => {
|
|
if (nb.parentId && nbMap.has(nb.parentId)) {
|
|
nbMap.get(nb.parentId).children.push(nb);
|
|
} else {
|
|
root.push(nb);
|
|
}
|
|
});
|
|
|
|
return textResult(root);
|
|
}
|
|
|
|
// ═══ 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}`);
|
|
}
|
|
});
|
|
}
|