246 lines
9.0 KiB
TypeScript
246 lines
9.0 KiB
TypeScript
/**
|
|
* MCP tools for rDocs (notebooks & docs — full TipTap editor).
|
|
*
|
|
* Tools: rdocs_list_notebooks, rdocs_list_docs, rdocs_get_doc,
|
|
* rdocs_create_doc, rdocs_update_doc
|
|
*/
|
|
|
|
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/rdocs/schemas";
|
|
import type { NotebookDoc, NoteItem } from "../../modules/rdocs/schemas";
|
|
import { resolveAccess, accessDeniedResponse } from "./_auth";
|
|
import { filterArrayByVisibility, isVisibleTo } from "../../shared/membrane";
|
|
|
|
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 registerDocsTools(server: McpServer, syncServer: SyncServer) {
|
|
server.tool(
|
|
"rdocs_list_notebooks",
|
|
"List all notebooks in a space (rDocs)",
|
|
{
|
|
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<NotebookDoc>(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(
|
|
"rdocs_list_docs",
|
|
"List docs, 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<NoteItem & { notebookTitle: string }> = [];
|
|
for (const docId of docIds) {
|
|
const doc = syncServer.getDoc<NotebookDoc>(docId);
|
|
if (!doc?.items) continue;
|
|
const nbTitle = doc.notebook?.title || "Untitled";
|
|
const visibleItems = filterArrayByVisibility(Object.values(doc.items), access.role);
|
|
for (const note of visibleItems) {
|
|
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(
|
|
"rdocs_get_doc",
|
|
"Get the full content of a specific doc",
|
|
{
|
|
space: z.string().describe("Space slug"),
|
|
token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"),
|
|
note_id: z.string().describe("Doc/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<NotebookDoc>(notebookDocId(space, notebook_id));
|
|
const note = doc?.items?.[note_id];
|
|
if (note) {
|
|
if (!isVisibleTo(note.visibility, access.role)) {
|
|
return { content: [{ type: "text", text: JSON.stringify({ error: "Doc not found" }) }] };
|
|
}
|
|
return { content: [{ type: "text", text: JSON.stringify(note, null, 2) }] };
|
|
}
|
|
}
|
|
|
|
for (const docId of findNotebookDocIds(syncServer, space)) {
|
|
const doc = syncServer.getDoc<NotebookDoc>(docId);
|
|
const note = doc?.items?.[note_id];
|
|
if (note) {
|
|
if (!isVisibleTo(note.visibility, access.role)) {
|
|
return { content: [{ type: "text", text: JSON.stringify({ error: "Doc not found" }) }] };
|
|
}
|
|
return { content: [{ type: "text", text: JSON.stringify(note, null, 2) }] };
|
|
}
|
|
}
|
|
|
|
return { content: [{ type: "text", text: JSON.stringify({ error: "Doc not found" }) }] };
|
|
},
|
|
);
|
|
|
|
server.tool(
|
|
"rdocs_create_doc",
|
|
"Create a new doc 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("Doc title"),
|
|
content: z.string().optional().describe("Doc content (plain text or HTML)"),
|
|
tags: z.array(z.string()).optional().describe("Doc tags"),
|
|
visibility: z.enum(['viewer', 'member', 'moderator', 'admin']).optional().describe("Object visibility level (default: viewer = everyone)"),
|
|
},
|
|
async ({ space, token, notebook_id, title, content, tags, visibility }) => {
|
|
const access = await resolveAccess(token, space, true);
|
|
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
|
|
|
const docId = notebookDocId(space, notebook_id);
|
|
const doc = syncServer.getDoc<NotebookDoc>(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 || [],
|
|
});
|
|
|
|
if (visibility) noteItem.visibility = visibility;
|
|
|
|
syncServer.changeDoc<NotebookDoc>(docId, `Create doc ${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(
|
|
"rdocs_update_doc",
|
|
"Update an existing doc (requires auth token + space membership)",
|
|
{
|
|
space: z.string().describe("Space slug"),
|
|
token: z.string().describe("JWT auth token"),
|
|
note_id: z.string().describe("Doc/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 doc"),
|
|
visibility: z.enum(['viewer', 'member', 'moderator', 'admin']).optional().describe("Object visibility level"),
|
|
},
|
|
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<NotebookDoc>(docId);
|
|
if (!doc?.items?.[note_id]) continue;
|
|
|
|
syncServer.changeDoc<NotebookDoc>(docId, `Update doc ${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;
|
|
if (updates.visibility !== undefined) n.visibility = updates.visibility || undefined;
|
|
n.updatedAt = Date.now();
|
|
});
|
|
|
|
return { content: [{ type: "text", text: JSON.stringify({ id: note_id, updated: true }) }] };
|
|
}
|
|
|
|
return { content: [{ type: "text", text: JSON.stringify({ error: "Doc not found" }) }], isError: true };
|
|
},
|
|
);
|
|
}
|