226 lines
7.6 KiB
TypeScript
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),
|
|
}],
|
|
};
|
|
},
|
|
);
|
|
}
|