/** * MCP tools for rNotes (vault browser — Obsidian/Logseq sync). * * Tools: rnotes_list_vaults, rnotes_browse_vault, rnotes_search_vault, * rnotes_get_vault_note, rnotes_sync_status */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { SyncServer } from "../local-first/sync-server"; import { vaultDocId } from "../../modules/rnotes/schemas"; import type { VaultDoc } from "../../modules/rnotes/schemas"; import { resolveAccess, accessDeniedResponse } from "./_auth"; import { filterArrayByVisibility, isVisibleTo } from "../../shared/membrane"; import { readFile } from "fs/promises"; import { join } from "path"; const VAULT_PREFIX = ":rnotes:vaults:"; const VAULT_DIR = "/data/files/uploads/vaults"; /** Find all vault docIds for a space. */ function findVaultDocIds(syncServer: SyncServer, space: string): string[] { const prefix = `${space}${VAULT_PREFIX}`; return syncServer.getDocIds().filter(id => id.startsWith(prefix)); } /** Read a single file from a vault ZIP on disk. */ async function readVaultFile(vaultId: string, filePath: string): Promise { try { const zipPath = join(VAULT_DIR, `${vaultId}.zip`); const JSZip = (await import("jszip")).default; const data = await readFile(zipPath); const zip = await JSZip.loadAsync(data); const entry = zip.file(filePath); if (!entry) return null; return await entry.async("string"); } catch { return null; } } export function registerNotesTools(server: McpServer, syncServer: SyncServer) { server.tool( "rnotes_list_vaults", "List all synced vaults (Obsidian/Logseq) in a space", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), }, async ({ space, token }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const docIds = findVaultDocIds(syncServer, space); const vaults = []; for (const docId of docIds) { const doc = syncServer.getDoc(docId); if (!doc?.vault) continue; vaults.push({ id: doc.vault.id, name: doc.vault.name, source: doc.vault.source, totalNotes: doc.vault.totalNotes, lastSyncedAt: doc.vault.lastSyncedAt, createdAt: doc.vault.createdAt, }); } return { content: [{ type: "text", text: JSON.stringify(vaults, null, 2) }] }; }, ); server.tool( "rnotes_browse_vault", "Browse notes in a vault, optionally filtered by folder path", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), vault_id: z.string().describe("Vault ID"), folder: z.string().optional().describe("Folder path prefix (e.g. 'daily/')"), limit: z.number().optional().describe("Max results (default 50)"), }, async ({ space, token, vault_id, folder, limit }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(vaultDocId(space, vault_id)); if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "Vault not found" }) }] }; let notes = filterArrayByVisibility(Object.values(doc.notes || {}), access.role); if (folder) { notes = notes.filter(n => n.path.startsWith(folder)); } notes.sort((a, b) => b.lastModifiedAt - a.lastModifiedAt); const maxResults = limit || 50; notes = notes.slice(0, maxResults); const summary = notes.map(n => ({ path: n.path, title: n.title, tags: n.tags, sizeBytes: n.sizeBytes, lastModifiedAt: n.lastModifiedAt, syncStatus: n.syncStatus, })); return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; }, ); server.tool( "rnotes_search_vault", "Search notes across all vaults by title or tags", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), search: z.string().describe("Search term (matches title and tags)"), vault_id: z.string().optional().describe("Limit to specific vault"), limit: z.number().optional().describe("Max results (default 20)"), }, async ({ space, token, search, vault_id, limit }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const docIds = vault_id ? [vaultDocId(space, vault_id)] : findVaultDocIds(syncServer, space); const q = search.toLowerCase(); const results: Array<{ vaultName: string; path: string; title: string; tags: string[] }> = []; for (const docId of docIds) { const doc = syncServer.getDoc(docId); if (!doc?.notes) continue; const vaultName = doc.vault?.name || "Unknown"; const visibleNotes = filterArrayByVisibility(Object.values(doc.notes), access.role); for (const note of visibleNotes) { if ( note.title.toLowerCase().includes(q) || note.path.toLowerCase().includes(q) || note.tags.some(t => t.toLowerCase().includes(q)) ) { results.push({ vaultName, path: note.path, title: note.title, tags: note.tags }); } } } const maxResults = limit || 20; return { content: [{ type: "text", text: JSON.stringify(results.slice(0, maxResults), null, 2) }] }; }, ); server.tool( "rnotes_get_vault_note", "Get the full content of a vault note (reads from disk ZIP)", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), vault_id: z.string().describe("Vault ID"), path: z.string().describe("Note file path within vault"), }, async ({ space, token, vault_id, path: notePath }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(vaultDocId(space, vault_id)); if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "Vault not found" }) }] }; const meta = doc.notes?.[notePath]; if (!meta || !isVisibleTo(meta.visibility, access.role)) { return { content: [{ type: "text", text: JSON.stringify({ error: "Note not found in vault" }) }] }; } const content = await readVaultFile(vault_id, notePath); if (content === null) { return { content: [{ type: "text", text: JSON.stringify({ error: "Could not read note from vault ZIP" }) }] }; } return { content: [{ type: "text", text: JSON.stringify({ ...meta, content }, null, 2), }], }; }, ); server.tool( "rnotes_sync_status", "Get sync status summary for a vault", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), vault_id: z.string().describe("Vault ID"), }, async ({ space, token, vault_id }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(vaultDocId(space, vault_id)); if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "Vault not found" }) }] }; const notes = Object.values(doc.notes || {}); const synced = notes.filter(n => n.syncStatus === 'synced').length; const modified = notes.filter(n => n.syncStatus === 'local-modified').length; const conflicts = notes.filter(n => n.syncStatus === 'conflict').length; return { content: [{ type: "text", text: JSON.stringify({ vaultId: vault_id, vaultName: doc.vault?.name, source: doc.vault?.source, totalNotes: notes.length, synced, modified, conflicts, lastSyncedAt: doc.vault?.lastSyncedAt, }, null, 2), }], }; }, ); }