From e1426c1ef6eb9020dd43ffa5d599f001159fad7c Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Mon, 9 Mar 2026 02:02:09 +0100 Subject: [PATCH] feat: add MCP server with 14 tools and shared notes service - Shared notes-service.ts with userId-scoped queries for DRY - McpServer with tools: list/get/create/update/trash/restore/ archive/unarchive/delete notes, search, list tags, pin, reorder, upload image - Streamable HTTP endpoint at POST /api/mcp - Session management with GET/DELETE handlers Co-Authored-By: Claude Opus 4.6 --- src/lib/server/mcp/server.ts | 342 ++++++++++++++++++++++++ src/lib/server/notes-service.ts | 236 ++++++++++++++++ src/routes/api/mcp/+server.ts | 80 ++++++ src/routes/api/notes/reorder/+server.ts | 14 +- 4 files changed, 660 insertions(+), 12 deletions(-) create mode 100644 src/lib/server/mcp/server.ts create mode 100644 src/lib/server/notes-service.ts create mode 100644 src/routes/api/mcp/+server.ts diff --git a/src/lib/server/mcp/server.ts b/src/lib/server/mcp/server.ts new file mode 100644 index 0000000..058e2a4 --- /dev/null +++ b/src/lib/server/mcp/server.ts @@ -0,0 +1,342 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { + listNotes, + getNote, + createNote, + updateNote, + deleteNote, + searchNotes, + listAllTags, + reorderNotes +} from '../notes-service.js'; +import { saveAttachment } from '../attachments.js'; + +const NOTE_COLORS = [ + 'default', + 'coral', + 'peach', + 'sand', + 'mint', + 'sage', + 'fog', + 'storm', + 'dusk', + 'blossom', + 'clay', + 'chalk' +] as const; + +export function createMcpServer(userId: number): McpServer { + const server = new McpServer({ + name: 'crumbs', + version: '0.7.2' + }); + + server.tool( + 'list_notes', + 'List notes with optional filter (all, archived, trashed) and tag filter', + { + filter: z + .enum(['all', 'archived', 'trashed']) + .optional() + .describe('Filter notes by status'), + tag: z.string().optional().describe('Filter by tag name') + }, + async ({ filter = 'all', tag }) => { + let result = listNotes(userId, filter); + + if (tag) { + result = result.filter((n) => + (n.tags ?? []).some((t: string) => t.toLowerCase() === tag.toLowerCase()) + ); + } + + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] + }; + } + ); + + server.tool( + 'get_note', + 'Get a single note by ID with its tags and attachments', + { id: z.string().describe('Note ID') }, + async ({ id }) => { + const note = getNote(userId, id); + if (!note) { + return { + content: [{ type: 'text' as const, text: 'Note not found' }], + isError: true + }; + } + + return { + content: [{ type: 'text' as const, text: JSON.stringify(note, null, 2) }] + }; + } + ); + + server.tool( + 'create_note', + 'Create a new note', + { + title: z.string().optional().describe('Note title'), + content: z.string().optional().describe('Note content (supports markdown)'), + color: z.enum(NOTE_COLORS).optional().describe('Note color'), + checklistMode: z.boolean().optional().describe('Enable checklist mode') + }, + async ({ title, content, color, checklistMode }) => { + const note = createNote(userId, { title, content, color, checklistMode }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(note, null, 2) }] + }; + } + ); + + server.tool( + 'update_note', + 'Update an existing note', + { + id: z.string().describe('Note ID'), + title: z.string().optional().describe('New title'), + content: z.string().optional().describe('New content'), + color: z.enum(NOTE_COLORS).optional().describe('New color'), + pinned: z.boolean().optional().describe('Pin/unpin'), + checklistMode: z.boolean().optional().describe('Enable/disable checklist mode') + }, + async ({ id, ...updates }) => { + const result = updateNote(userId, id, updates); + if (!result) { + return { + content: [{ type: 'text' as const, text: 'Note not found' }], + isError: true + }; + } + + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] + }; + } + ); + + server.tool( + 'trash_note', + 'Move a note to trash', + { id: z.string().describe('Note ID') }, + async ({ id }) => { + const result = updateNote(userId, id, { trashed: true }); + if (!result) { + return { + content: [{ type: 'text' as const, text: 'Note not found' }], + isError: true + }; + } + return { + content: [{ type: 'text' as const, text: `Note "${result.title}" trashed` }] + }; + } + ); + + server.tool( + 'restore_note', + 'Restore a note from trash', + { id: z.string().describe('Note ID') }, + async ({ id }) => { + const result = updateNote(userId, id, { trashed: false }); + if (!result) { + return { + content: [{ type: 'text' as const, text: 'Note not found' }], + isError: true + }; + } + return { + content: [{ type: 'text' as const, text: `Note "${result.title}" restored` }] + }; + } + ); + + server.tool( + 'archive_note', + 'Archive a note', + { id: z.string().describe('Note ID') }, + async ({ id }) => { + const result = updateNote(userId, id, { archived: true }); + if (!result) { + return { + content: [{ type: 'text' as const, text: 'Note not found' }], + isError: true + }; + } + return { + content: [{ type: 'text' as const, text: `Note "${result.title}" archived` }] + }; + } + ); + + server.tool( + 'unarchive_note', + 'Unarchive a note', + { id: z.string().describe('Note ID') }, + async ({ id }) => { + const result = updateNote(userId, id, { archived: false }); + if (!result) { + return { + content: [{ type: 'text' as const, text: 'Note not found' }], + isError: true + }; + } + return { + content: [{ type: 'text' as const, text: `Note "${result.title}" unarchived` }] + }; + } + ); + + server.tool( + 'delete_note', + 'Permanently delete a note', + { id: z.string().describe('Note ID') }, + async ({ id }) => { + const existing = getNote(userId, id); + if (!existing) { + return { + content: [{ type: 'text' as const, text: 'Note not found' }], + isError: true + }; + } + + deleteNote(userId, id); + return { + content: [ + { + type: 'text' as const, + text: `Note "${existing.title}" permanently deleted` + } + ] + }; + } + ); + + server.tool( + 'search_notes', + 'Search notes by title, content, or tag name', + { query: z.string().describe('Search query') }, + async ({ query }) => { + const results = searchNotes(userId, query); + return { + content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] + }; + } + ); + + server.tool('list_tags', 'List all tags', {}, async () => { + const allTags = listAllTags(userId); + return { + content: [{ type: 'text' as const, text: JSON.stringify(allTags, null, 2) }] + }; + }); + + server.tool( + 'pin_note', + 'Pin or unpin a note', + { + id: z.string().describe('Note ID'), + pinned: z.boolean().describe('Whether to pin (true) or unpin (false)') + }, + async ({ id, pinned }) => { + const result = updateNote(userId, id, { pinned }); + if (!result) { + return { + content: [{ type: 'text' as const, text: 'Note not found' }], + isError: true + }; + } + return { + content: [ + { + type: 'text' as const, + text: `Note "${result.title}" ${pinned ? 'pinned' : 'unpinned'}` + } + ] + }; + } + ); + + server.tool( + 'reorder_notes', + 'Reorder notes by setting sort orders', + { + orders: z + .array( + z.object({ + id: z.string().describe('Note ID'), + sortOrder: z.number().describe('New sort order') + }) + ) + .describe('Array of note IDs with their new sort orders') + }, + async ({ orders }) => { + reorderNotes(userId, orders); + return { + content: [{ type: 'text' as const, text: `Reordered ${orders.length} notes` }] + }; + } + ); + + server.tool( + 'upload_image', + 'Attach an image to a note by fetching it from a URL', + { + noteId: z.string().describe('Note ID to attach the image to'), + imageUrl: z.string().url().describe('URL of the image to fetch') + }, + async ({ noteId, imageUrl }) => { + const existing = getNote(userId, noteId); + if (!existing) { + return { + content: [{ type: 'text' as const, text: 'Note not found' }], + isError: true + }; + } + + try { + const response = await fetch(imageUrl); + if (!response.ok) { + return { + content: [ + { + type: 'text' as const, + text: `Failed to fetch image: ${response.status} ${response.statusText}` + } + ], + isError: true + }; + } + + const contentType = response.headers.get('content-type') || 'image/png'; + const buffer = Buffer.from(await response.arrayBuffer()); + const ext = contentType.split('/')[1]?.split(';')[0] || 'png'; + const filename = `upload_${Date.now()}.${ext}`; + + const file = new File([buffer], filename, { type: contentType }); + const attachment = await saveAttachment(noteId, file, userId); + + return { + content: [{ type: 'text' as const, text: JSON.stringify(attachment, null, 2) }] + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Failed to upload image: ${err instanceof Error ? err.message : String(err)}` + } + ], + isError: true + }; + } + } + ); + + return server; +} diff --git a/src/lib/server/notes-service.ts b/src/lib/server/notes-service.ts new file mode 100644 index 0000000..bbb53ca --- /dev/null +++ b/src/lib/server/notes-service.ts @@ -0,0 +1,236 @@ +import { db as defaultDb } from './db/index.js'; +import { notes, noteTags, tags } from './db/schema.js'; +import { eq, and, desc, like, or, inArray } from 'drizzle-orm'; +import { v4 as uuidv4 } from 'uuid'; +import { extractTags } from '$lib/utils/tags.js'; +import { fetchTagsForNotes, syncNoteTags } from './tags.js'; +import { fetchAttachmentsForNotes } from './attachments.js'; +import type { NoteFilter } from '$lib/types/index.js'; + +const db = defaultDb; + +export function listNotes(userId: number, filter: NoteFilter = 'all') { + let conditions; + switch (filter) { + case 'archived': + conditions = and(eq(notes.userId, userId), eq(notes.archived, true), eq(notes.trashed, false)); + break; + case 'trashed': + conditions = and(eq(notes.userId, userId), eq(notes.trashed, true)); + break; + default: + conditions = and(eq(notes.userId, userId), eq(notes.archived, false), eq(notes.trashed, false)); + } + + const result = db + .select() + .from(notes) + .where(conditions) + .orderBy(desc(notes.pinned), desc(notes.updatedAt)) + .all(); + + const noteIds = result.map((n) => n.id); + const tagMap = fetchTagsForNotes(noteIds); + const attachmentMap = fetchAttachmentsForNotes(noteIds); + return result.map((note) => ({ + ...note, + tags: tagMap.get(note.id) ?? [], + attachments: attachmentMap.get(note.id) ?? [] + })); +} + +export function getNote(userId: number, id: string) { + const note = db + .select() + .from(notes) + .where(and(eq(notes.id, id), eq(notes.userId, userId))) + .get(); + if (!note) return null; + + const tagMap = fetchTagsForNotes([id]); + const attachmentMap = fetchAttachmentsForNotes([id]); + return { + ...note, + tags: tagMap.get(id) ?? [], + attachments: attachmentMap.get(id) ?? [] + }; +} + +export interface CreateNoteInput { + id?: string; + title?: string; + content?: string; + color?: string; + pinned?: boolean; + checklistMode?: boolean; + sortOrder?: number; +} + +export function createNote(userId: number, input: CreateNoteInput) { + const now = new Date(); + const id = input.id || uuidv4(); + + const newNote = { + id, + userId, + title: input.title || '', + content: input.content || '', + color: input.color || 'default', + pinned: input.pinned || false, + archived: false, + trashed: false, + trashedAt: null, + checklistMode: input.checklistMode || false, + sortOrder: input.sortOrder || 0, + createdAt: now, + updatedAt: now, + version: 1 + }; + + const content = `${newNote.title} ${newNote.content}`; + const extractedTags = extractTags(content); + + db.transaction((tx) => { + tx.insert(notes).values(newNote).run(); + }); + syncNoteTags(id, extractedTags); + + return { ...newNote, tags: extractedTags }; +} + +export interface UpdateNoteInput { + title?: string; + content?: string; + color?: string; + pinned?: boolean; + archived?: boolean; + trashed?: boolean; + checklistMode?: boolean; + sortOrder?: number; +} + +export function updateNote(userId: number, id: string, input: UpdateNoteInput) { + const existing = db + .select() + .from(notes) + .where(and(eq(notes.id, id), eq(notes.userId, userId))) + .get(); + if (!existing) return null; + + const now = new Date(); + const updates: Record = { + updatedAt: now, + version: existing.version + 1 + }; + + if (input.title !== undefined) updates.title = input.title; + if (input.content !== undefined) updates.content = input.content; + if (input.color !== undefined) updates.color = input.color; + if (input.pinned !== undefined) updates.pinned = input.pinned; + if (input.archived !== undefined) updates.archived = input.archived; + if (input.trashed !== undefined) { + updates.trashed = input.trashed; + updates.trashedAt = input.trashed ? now : null; + } + if (input.checklistMode !== undefined) updates.checklistMode = input.checklistMode; + if (input.sortOrder !== undefined) updates.sortOrder = input.sortOrder; + + db.update(notes) + .set(updates) + .where(and(eq(notes.id, id), eq(notes.userId, userId))) + .run(); + + const updated = db + .select() + .from(notes) + .where(and(eq(notes.id, id), eq(notes.userId, userId))) + .get(); + + if (input.title !== undefined || input.content !== undefined) { + const content = `${updated!.title} ${updated!.content}`; + const extractedTags = extractTags(content); + syncNoteTags(id, extractedTags); + } + + const tagMap = fetchTagsForNotes([id]); + const attachmentMap = fetchAttachmentsForNotes([id]); + return { + ...updated, + tags: tagMap.get(id) ?? [], + attachments: attachmentMap.get(id) ?? [] + }; +} + +export function deleteNote(userId: number, id: string): boolean { + const existing = db + .select() + .from(notes) + .where(and(eq(notes.id, id), eq(notes.userId, userId))) + .get(); + if (!existing) return false; + + db.delete(notes) + .where(and(eq(notes.id, id), eq(notes.userId, userId))) + .run(); + return true; +} + +export function searchNotes(userId: number, query: string) { + if (!query) return []; + + const pattern = `%${query}%`; + + const results = db + .select() + .from(notes) + .where( + and( + eq(notes.userId, userId), + eq(notes.trashed, false), + or(like(notes.title, pattern), like(notes.content, pattern)) + ) + ) + .all(); + + const tagResults = db + .select({ noteId: noteTags.noteId }) + .from(noteTags) + .innerJoin(tags, eq(noteTags.tagId, tags.id)) + .where(and(like(tags.name, pattern), eq(tags.userId, userId))) + .all(); + + const resultIds = new Set(results.map((n) => n.id)); + const extraNoteIds = [...new Set(tagResults.map((r) => r.noteId))].filter( + (id) => !resultIds.has(id) + ); + + let extraNotes: typeof results = []; + if (extraNoteIds.length > 0) { + extraNotes = db + .select() + .from(notes) + .where(and(eq(notes.userId, userId), inArray(notes.id, extraNoteIds), eq(notes.trashed, false))) + .all(); + } + + const combined = [...results, ...extraNotes]; + const tagMap = fetchTagsForNotes(combined.map((n) => n.id)); + return combined.map((note) => ({ + ...note, + tags: tagMap.get(note.id) ?? [] + })); +} + +export function listAllTags(userId: number) { + return db.select().from(tags).where(eq(tags.userId, userId)).all(); +} + +export function reorderNotes(userId: number, orders: { id: string; sortOrder: number }[]) { + const now = new Date(); + for (const { id, sortOrder } of orders) { + db.update(notes) + .set({ sortOrder, updatedAt: now }) + .where(and(eq(notes.id, id), eq(notes.userId, userId))) + .run(); + } +} diff --git a/src/routes/api/mcp/+server.ts b/src/routes/api/mcp/+server.ts new file mode 100644 index 0000000..511dde9 --- /dev/null +++ b/src/routes/api/mcp/+server.ts @@ -0,0 +1,80 @@ +import type { RequestHandler } from './$types.js'; +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; +import { createMcpServer } from '$lib/server/mcp/server.js'; +import { getUserId } from '$lib/server/api-utils.js'; + +const transports = new Map(); + +export const POST: RequestHandler = async ({ request, ...event }) => { + const userId = getUserId(event); + const sessionId = request.headers.get('mcp-session-id'); + const body = await request.json(); + + // Reuse existing session + if (sessionId && transports.has(sessionId)) { + const transport = transports.get(sessionId)!; + return transport.handleRequest(request, { parsedBody: body }); + } + + // New session (initialize request) + if (!sessionId && isInitializeRequest(body)) { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + enableJsonResponse: true, + onsessioninitialized: (id) => { + transports.set(id, transport); + } + }); + + transport.onclose = () => { + if (transport.sessionId) { + transports.delete(transport.sessionId); + } + }; + + const server = createMcpServer(userId); + await server.connect(transport); + + return transport.handleRequest(request, { parsedBody: body }); + } + + // Invalid request + return new Response( + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Invalid session' }, + id: null + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' } + } + ); +}; + +export const GET: RequestHandler = async ({ request }) => { + const sessionId = request.headers.get('mcp-session-id'); + + if (sessionId && transports.has(sessionId)) { + return transports.get(sessionId)!.handleRequest(request); + } + + return new Response(JSON.stringify({ error: 'Invalid session' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); +}; + +export const DELETE: RequestHandler = async ({ request }) => { + const sessionId = request.headers.get('mcp-session-id'); + + if (sessionId && transports.has(sessionId)) { + return transports.get(sessionId)!.handleRequest(request); + } + + return new Response(JSON.stringify({ error: 'Invalid session' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); +}; diff --git a/src/routes/api/notes/reorder/+server.ts b/src/routes/api/notes/reorder/+server.ts index a7953cc..fee8ce2 100644 --- a/src/routes/api/notes/reorder/+server.ts +++ b/src/routes/api/notes/reorder/+server.ts @@ -1,8 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; -import { db } from '$lib/server/db/index.js'; -import { notes } from '$lib/server/db/schema.js'; -import { eq, and } from 'drizzle-orm'; +import { reorderNotes } from '$lib/server/notes-service.js'; import { getUserId } from '$lib/server/api-utils.js'; export const POST: RequestHandler = async ({ request, ...event }) => { @@ -14,14 +12,6 @@ export const POST: RequestHandler = async ({ request, ...event }) => { return json({ error: 'Invalid payload' }, { status: 400 }); } - db.transaction((tx) => { - for (const { id, sortOrder } of orders) { - tx.update(notes) - .set({ sortOrder }) - .where(and(eq(notes.id, id), eq(notes.userId, userId))) - .run(); - } - }); - + reorderNotes(userId, orders); return json({ ok: true }); };