- Add reminders page with navigation support - Upgrade BMad builder module to skills-based architecture - Refactor MCP server: extract tools and auth into separate modules - Add connections cache, custom AI provider support - Update prisma schema and generated client - Various UI/UX improvements and i18n updates - Add service worker for PWA support Made-with: Cursor
1292 lines
52 KiB
JavaScript
1292 lines
52 KiB
JavaScript
/**
|
|
* 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}`);
|
|
}
|
|
});
|
|
}
|