/** * MCP tools for rNotes (notebooks & notes). * * Tools: rnotes_list_notebooks, rnotes_list_notes, rnotes_get_note, * rnotes_create_note, rnotes_update_note */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { SyncServer } from "../local-first/sync-server"; import { notebookDocId, createNoteItem } from "../../modules/rnotes/schemas"; import type { NotebookDoc, NoteItem } from "../../modules/rnotes/schemas"; import { resolveAccess, accessDeniedResponse } from "./_auth"; const NOTEBOOK_PREFIX = ":notes:notebooks:"; /** Find all notebook docIds for a space. */ function findNotebookDocIds(syncServer: SyncServer, space: string): string[] { const prefix = `${space}${NOTEBOOK_PREFIX}`; return syncServer.getDocIds().filter(id => id.startsWith(prefix)); } export function registerNotesTools(server: McpServer, syncServer: SyncServer) { server.tool( "rnotes_list_notebooks", "List all notebooks in a space", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), }, async ({ space, token }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const docIds = findNotebookDocIds(syncServer, space); const notebooks = []; for (const docId of docIds) { const doc = syncServer.getDoc(docId); if (!doc?.notebook) continue; notebooks.push({ id: doc.notebook.id, title: doc.notebook.title, slug: doc.notebook.slug, description: doc.notebook.description, noteCount: Object.keys(doc.items || {}).length, createdAt: doc.notebook.createdAt, updatedAt: doc.notebook.updatedAt, }); } return { content: [{ type: "text", text: JSON.stringify(notebooks, null, 2) }] }; }, ); server.tool( "rnotes_list_notes", "List notes, optionally filtered by notebook, search text, or tags", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), notebook_id: z.string().optional().describe("Filter by notebook ID"), search: z.string().optional().describe("Search in title/content"), limit: z.number().optional().describe("Max results (default 50)"), tags: z.array(z.string()).optional().describe("Filter by tags"), }, async ({ space, token, notebook_id, search, limit, tags }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const docIds = notebook_id ? [notebookDocId(space, notebook_id)] : findNotebookDocIds(syncServer, space); let notes: Array = []; for (const docId of docIds) { const doc = syncServer.getDoc(docId); if (!doc?.items) continue; const nbTitle = doc.notebook?.title || "Untitled"; for (const note of Object.values(doc.items)) { notes.push({ ...JSON.parse(JSON.stringify(note)), notebookTitle: nbTitle }); } } if (search) { const q = search.toLowerCase(); notes = notes.filter(n => n.title.toLowerCase().includes(q) || (n.contentPlain && n.contentPlain.toLowerCase().includes(q)), ); } if (tags && tags.length > 0) { notes = notes.filter(n => n.tags && tags.some(t => n.tags.includes(t)), ); } notes.sort((a, b) => b.updatedAt - a.updatedAt); const maxResults = limit || 50; notes = notes.slice(0, maxResults); const summary = notes.map(n => ({ id: n.id, notebookId: n.notebookId, notebookTitle: n.notebookTitle, title: n.title, type: n.type, tags: n.tags, isPinned: n.isPinned, contentPreview: (n.contentPlain || "").slice(0, 200), createdAt: n.createdAt, updatedAt: n.updatedAt, })); return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; }, ); server.tool( "rnotes_get_note", "Get the full content of a specific note", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), note_id: z.string().describe("Note ID"), notebook_id: z.string().optional().describe("Notebook ID (speeds up lookup)"), }, async ({ space, token, note_id, notebook_id }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); if (notebook_id) { const doc = syncServer.getDoc(notebookDocId(space, notebook_id)); const note = doc?.items?.[note_id]; if (note) { return { content: [{ type: "text", text: JSON.stringify(note, null, 2) }] }; } } for (const docId of findNotebookDocIds(syncServer, space)) { const doc = syncServer.getDoc(docId); const note = doc?.items?.[note_id]; if (note) { return { content: [{ type: "text", text: JSON.stringify(note, null, 2) }] }; } } return { content: [{ type: "text", text: JSON.stringify({ error: "Note not found" }) }] }; }, ); server.tool( "rnotes_create_note", "Create a new note in a notebook (requires auth token + space membership)", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), notebook_id: z.string().describe("Target notebook ID"), title: z.string().describe("Note title"), content: z.string().optional().describe("Note content (plain text or HTML)"), tags: z.array(z.string()).optional().describe("Note tags"), }, async ({ space, token, notebook_id, title, content, tags }) => { const access = await resolveAccess(token, space, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const docId = notebookDocId(space, notebook_id); const doc = syncServer.getDoc(docId); if (!doc) { return { content: [{ type: "text", text: JSON.stringify({ error: "Notebook not found" }) }], isError: true }; } const noteId = `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const noteItem = createNoteItem(noteId, notebook_id, title, { content: content || "", contentPlain: content || "", contentFormat: "html", tags: tags || [], }); syncServer.changeDoc(docId, `Create note ${title}`, (d) => { if (!d.items) (d as any).items = {}; d.items[noteId] = noteItem; }); return { content: [{ type: "text", text: JSON.stringify({ id: noteId, created: true }) }] }; }, ); server.tool( "rnotes_update_note", "Update an existing note (requires auth token + space membership)", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), note_id: z.string().describe("Note ID"), notebook_id: z.string().optional().describe("Notebook ID (speeds up lookup)"), title: z.string().optional().describe("New title"), content: z.string().optional().describe("New content"), tags: z.array(z.string()).optional().describe("New tags"), is_pinned: z.boolean().optional().describe("Pin/unpin note"), }, async ({ space, token, note_id, notebook_id, ...updates }) => { const access = await resolveAccess(token, space, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const docIds = notebook_id ? [notebookDocId(space, notebook_id)] : findNotebookDocIds(syncServer, space); for (const docId of docIds) { const doc = syncServer.getDoc(docId); if (!doc?.items?.[note_id]) continue; syncServer.changeDoc(docId, `Update note ${note_id}`, (d) => { const n = d.items[note_id]; if (updates.title !== undefined) n.title = updates.title; if (updates.content !== undefined) { n.content = updates.content; n.contentPlain = updates.content; } if (updates.tags !== undefined) n.tags = updates.tags; if (updates.is_pinned !== undefined) n.isPinned = updates.is_pinned; n.updatedAt = Date.now(); }); return { content: [{ type: "text", text: JSON.stringify({ id: note_id, updated: true }) }] }; } return { content: [{ type: "text", text: JSON.stringify({ error: "Note not found" }) }], isError: true }; }, ); }