- Add debounced state updates for title and content (500ms delay) - Immediate UI updates with delayed history saving - Prevent one-letter-per-undo issue - Add cleanup for debounce timers on unmount
509 lines
13 KiB
JavaScript
509 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
import {
|
|
CallToolRequestSchema,
|
|
ErrorCode,
|
|
ListToolsRequestSchema,
|
|
McpError,
|
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
import { PrismaClient } from '@prisma/client';
|
|
import { fileURLToPath } from 'url';
|
|
import { dirname, join } from 'path';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
// Initialize Prisma Client
|
|
const prisma = new PrismaClient({
|
|
datasources: {
|
|
db: {
|
|
url: `file:${join(__dirname, '../keep-notes/prisma/dev.db')}`
|
|
}
|
|
}
|
|
});
|
|
|
|
// Helper to parse JSON fields
|
|
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,
|
|
};
|
|
}
|
|
|
|
// Create MCP server
|
|
const server = new Server(
|
|
{
|
|
name: 'memento-mcp-server',
|
|
version: '1.0.0',
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {},
|
|
},
|
|
}
|
|
);
|
|
|
|
// List available tools
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
return {
|
|
tools: [
|
|
{
|
|
name: 'create_note',
|
|
description: 'Create a new note in Memento',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
title: {
|
|
type: 'string',
|
|
description: 'Note title (optional)',
|
|
},
|
|
content: {
|
|
type: 'string',
|
|
description: 'Note content',
|
|
},
|
|
color: {
|
|
type: 'string',
|
|
description: 'Note color (default, red, orange, yellow, green, teal, blue, purple, pink, gray)',
|
|
default: 'default',
|
|
},
|
|
type: {
|
|
type: 'string',
|
|
enum: ['text', 'checklist'],
|
|
description: 'Note type',
|
|
default: 'text',
|
|
},
|
|
checkItems: {
|
|
type: 'array',
|
|
description: 'Checklist items (if 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: 'Archive the note',
|
|
default: false,
|
|
},
|
|
images: {
|
|
type: 'array',
|
|
description: 'Note images as base64 encoded strings',
|
|
items: { type: 'string' },
|
|
},
|
|
},
|
|
required: ['content'],
|
|
},
|
|
},
|
|
{
|
|
name: 'get_notes',
|
|
description: 'Get all notes from Memento',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
includeArchived: {
|
|
type: 'boolean',
|
|
description: 'Include archived notes',
|
|
default: false,
|
|
},
|
|
search: {
|
|
type: 'string',
|
|
description: 'Search query to filter notes',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: 'get_note',
|
|
description: 'Get a specific note by ID',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
id: {
|
|
type: 'string',
|
|
description: 'Note ID',
|
|
},
|
|
},
|
|
required: ['id'],
|
|
},
|
|
},
|
|
{
|
|
name: 'update_note',
|
|
description: 'Update an existing note',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
id: {
|
|
type: 'string',
|
|
description: 'Note ID',
|
|
},
|
|
title: {
|
|
type: 'string',
|
|
description: 'Note title',
|
|
},
|
|
content: {
|
|
type: 'string',
|
|
description: 'Note content',
|
|
},
|
|
color: {
|
|
type: 'string',
|
|
description: 'Note color',
|
|
},
|
|
checkItems: {
|
|
type: 'array',
|
|
description: 'Checklist items',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
text: { type: 'string' },
|
|
checked: { type: 'boolean' },
|
|
},
|
|
},
|
|
},
|
|
labels: {
|
|
type: 'array',
|
|
description: 'Note labels',
|
|
items: { type: 'string' },
|
|
},
|
|
isPinned: {
|
|
type: 'boolean',
|
|
description: 'Pin status',
|
|
},
|
|
isArchived: {
|
|
type: 'boolean',
|
|
description: 'Archive status',
|
|
},
|
|
images: {
|
|
type: 'array',
|
|
description: 'Note images as base64 encoded strings',
|
|
items: { type: 'string' },
|
|
},
|
|
},
|
|
required: ['id'],
|
|
},
|
|
},
|
|
{
|
|
name: 'delete_note',
|
|
description: 'Delete a note by ID',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
id: {
|
|
type: 'string',
|
|
description: 'Note ID',
|
|
},
|
|
},
|
|
required: ['id'],
|
|
},
|
|
},
|
|
{
|
|
name: 'search_notes',
|
|
description: 'Search notes by query',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
query: {
|
|
type: 'string',
|
|
description: 'Search query',
|
|
},
|
|
},
|
|
required: ['query'],
|
|
},
|
|
},
|
|
{
|
|
name: 'get_labels',
|
|
description: 'Get all unique labels from notes',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {},
|
|
},
|
|
},
|
|
{
|
|
name: 'toggle_pin',
|
|
description: 'Toggle pin status of a note',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
id: {
|
|
type: 'string',
|
|
description: 'Note ID',
|
|
},
|
|
},
|
|
required: ['id'],
|
|
},
|
|
},
|
|
{
|
|
name: 'toggle_archive',
|
|
description: 'Toggle archive status of a note',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
id: {
|
|
type: 'string',
|
|
description: 'Note ID',
|
|
},
|
|
},
|
|
required: ['id'],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
});
|
|
|
|
// Handle tool calls
|
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
const { name, arguments: args } = request.params;
|
|
|
|
try {
|
|
switch (name) {
|
|
case 'create_note': {
|
|
const note = await prisma.note.create({
|
|
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,
|
|
},
|
|
});
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: JSON.stringify(parseNote(note), null, 2),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
case 'get_notes': {
|
|
let where = {};
|
|
if (!args.includeArchived) {
|
|
where.isArchived = false;
|
|
}
|
|
if (args.search) {
|
|
where.OR = [
|
|
{ title: { contains: args.search, mode: 'insensitive' } },
|
|
{ content: { contains: args.search, mode: 'insensitive' } },
|
|
];
|
|
}
|
|
|
|
const notes = await prisma.note.findMany({
|
|
where,
|
|
orderBy: [
|
|
{ isPinned: 'desc' },
|
|
{ order: 'asc' },
|
|
{ updatedAt: 'desc' },
|
|
],
|
|
});
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: JSON.stringify(notes.map(parseNote), null, 2),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
case 'get_note': {
|
|
const note = await prisma.note.findUnique({
|
|
where: { id: args.id },
|
|
});
|
|
if (!note) {
|
|
throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
|
}
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: JSON.stringify(parseNote(note), null, 2),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
case 'update_note': {
|
|
const updateData = { ...args };
|
|
delete updateData.id;
|
|
|
|
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;
|
|
}
|
|
updateData.updatedAt = new Date();
|
|
|
|
const note = await prisma.note.update({
|
|
where: { id: args.id },
|
|
data: updateData,
|
|
});
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: JSON.stringify(parseNote(note), null, 2),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
case 'delete_note': {
|
|
await prisma.note.delete({
|
|
where: { id: args.id },
|
|
});
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: JSON.stringify({ success: true, message: 'Note deleted' }),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
case 'search_notes': {
|
|
const notes = await prisma.note.findMany({
|
|
where: {
|
|
isArchived: false,
|
|
OR: [
|
|
{ title: { contains: args.query, mode: 'insensitive' } },
|
|
{ content: { contains: args.query, mode: 'insensitive' } },
|
|
],
|
|
},
|
|
orderBy: [
|
|
{ isPinned: 'desc' },
|
|
{ updatedAt: 'desc' },
|
|
],
|
|
});
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: JSON.stringify(notes.map(parseNote), null, 2),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
case 'get_labels': {
|
|
const notes = await prisma.note.findMany({
|
|
select: { labels: true },
|
|
});
|
|
|
|
const labelsSet = new Set();
|
|
notes.forEach((note) => {
|
|
const labels = note.labels ? JSON.parse(note.labels) : null;
|
|
if (labels) {
|
|
labels.forEach((label) => labelsSet.add(label));
|
|
}
|
|
});
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: JSON.stringify(Array.from(labelsSet).sort(), null, 2),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
case 'toggle_pin': {
|
|
const note = await prisma.note.findUnique({ where: { id: args.id } });
|
|
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 {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: JSON.stringify(parseNote(updated), null, 2),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
case 'toggle_archive': {
|
|
const note = await prisma.note.findUnique({ where: { id: args.id } });
|
|
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 {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: JSON.stringify(parseNote(updated), null, 2),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
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}`
|
|
);
|
|
}
|
|
});
|
|
|
|
// Start server
|
|
async function main() {
|
|
const transport = new StdioServerTransport();
|
|
await server.connect(transport);
|
|
console.error('Memento MCP server running on stdio');
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error('Server error:', error);
|
|
process.exit(1);
|
|
});
|