rspace-online/server/mcp-tools/rnotes.ts

226 lines
7.6 KiB
TypeScript

/**
* 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<string | null> {
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<VaultDoc>(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<VaultDoc>(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<VaultDoc>(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<VaultDoc>(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<VaultDoc>(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),
}],
};
},
);
}