forked from mirror/crumbs-original
- Add missing userId arg to syncNoteTags() calls in notes-service - Add seedUser option to createTestDb() for test setup - Add explicit type annotations to MCP server.tool() callbacks (zod v4) - Fix ambiguous getByText selector in settings e2e test Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
344 lines
8.4 KiB
TypeScript
344 lines
8.4 KiB
TypeScript
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
import { z } from 'zod';
|
|
import type { NoteFilter } from '$lib/types/index.js';
|
|
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 }: { filter?: string; tag?: string }) => {
|
|
let result = listNotes(userId, filter as NoteFilter);
|
|
|
|
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 }: { id: string }) => {
|
|
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 }: { title?: string; content?: string; color?: string; checklistMode?: boolean }) => {
|
|
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 }: { id: string; title?: string; content?: string; color?: string; pinned?: boolean; checklistMode?: boolean }) => {
|
|
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 }: { id: string }) => {
|
|
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 }: { id: string }) => {
|
|
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 }: { id: string }) => {
|
|
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 }: { id: string }) => {
|
|
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 }: { id: string }) => {
|
|
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 }: { query: string }) => {
|
|
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 }: { id: string; pinned: boolean }) => {
|
|
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 }: { orders: { id: string; sortOrder: number }[] }) => {
|
|
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 }: { noteId: string; imageUrl: string }) => {
|
|
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;
|
|
}
|