From a739d623186fc63385ce31b3e98b749b8e88a084 Mon Sep 17 00:00:00 2001 From: Max Nikitin Date: Tue, 10 Mar 2026 12:33:39 +0300 Subject: [PATCH 1/3] feat: add comment support (list, get, create, update, delete) Add 5 new MCP tools for managing Docmost page comments: - list_comments: list all comments on a page (paginated) - get_comment: retrieve a single comment by ID - create_comment: create page-level or inline comments with replies - update_comment: update existing comment content - delete_comment: remove a comment Comment content is automatically converted between Markdown (agent-facing) and ProseMirror/TipTap JSON (Docmost API). Uses the existing markdown-converter for reading and a new markdown-to-json utility for writing. Co-Authored-By: Claude Opus 4.6 --- README.md | 8 ++ src/index.ts | 185 ++++++++++++++++++++++++++++++++++++ src/lib/filters.ts | 15 +++ src/lib/markdown-to-json.ts | 25 +++++ 4 files changed, 233 insertions(+) create mode 100644 src/lib/markdown-to-json.ts diff --git a/README.md b/README.md index f9b0f00..8d82486 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,14 @@ A Model Context Protocol (MCP) server for [Docmost](https://docmost.com/), enabl - **`list_pages`**: List pages within a space (ordered by `updatedAt` descending). - **`get_page`**: Retrieve full content and metadata of a specific page. +### Comments + +- **`list_comments`**: List all comments on a page with content converted to Markdown. +- **`get_comment`**: Retrieve a single comment by ID. +- **`create_comment`**: Create a page-level or inline comment. Content is provided as Markdown and automatically converted to ProseMirror JSON. Supports replies via `parentCommentId`. +- **`update_comment`**: Update an existing comment's content (creator only). +- **`delete_comment`**: Delete a comment (creator or space admin only). + ### Technical Details - **Automatic Markdown Conversion**: Page content is automatically converted from Docmost's internal ProseMirror/TipTap JSON format to clean Markdown for easy agent consumption. Supports all Docmost extensions including callouts, task lists, math blocks, embeds, and more. diff --git a/src/index.ts b/src/index.ts index 83c251d..8198433 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { filterSpace, filterGroup, filterPage, + filterComment, filterSearchResult, } from "./lib/filters.js"; import { convertProseMirrorToMarkdown } from "./lib/markdown-converter.js"; @@ -16,6 +17,7 @@ import { fileURLToPath } from "url"; import { dirname, join } from "path"; import { updatePageContentRealtime } from "./lib/collaboration.js"; import { getCollabToken, performLogin } from "./lib/auth-utils.js"; +import { markdownToTiptapJson } from "./lib/markdown-to-json.js"; // Read version from package.json const __filename = fileURLToPath(import.meta.url); @@ -328,6 +330,80 @@ class DocmostClient { ); return Promise.all(promises); } + + // --- Comment Methods --- + + async listComments(pageId: string) { + const comments = await this.paginateAll("/comments", { pageId }); + return comments.map((comment: any) => { + const markdown = comment.content + ? convertProseMirrorToMarkdown(comment.content) + : ""; + return filterComment(comment, markdown); + }); + } + + async getComment(commentId: string) { + await this.ensureAuthenticated(); + const response = await this.client.post("/comments/info", { commentId }); + const comment = response.data.data || response.data; + const markdown = comment.content + ? convertProseMirrorToMarkdown(comment.content) + : ""; + return { + data: filterComment(comment, markdown), + success: true, + }; + } + + async createComment( + pageId: string, + content: string, + type: "page" | "inline" = "page", + selection?: string, + parentCommentId?: string, + ) { + await this.ensureAuthenticated(); + const jsonContent = await markdownToTiptapJson(content); + const payload: Record = { + pageId, + content: jsonContent, + type, + }; + if (selection) payload.selection = selection; + if (parentCommentId) payload.parentCommentId = parentCommentId; + + const response = await this.client.post("/comments/create", payload); + const comment = response.data.data || response.data; + const markdown = comment.content + ? convertProseMirrorToMarkdown(comment.content) + : content; + return { + data: filterComment(comment, markdown), + success: true, + }; + } + + async updateComment(commentId: string, content: string) { + await this.ensureAuthenticated(); + const jsonContent = await markdownToTiptapJson(content); + const response = await this.client.post("/comments/update", { + commentId, + content: jsonContent, + }); + return { + success: true, + commentId, + message: "Comment updated successfully.", + }; + } + + async deleteComment(commentId: string) { + await this.ensureAuthenticated(); + return this.client + .post("/comments/delete", { commentId }) + .then((res) => res.data); + } } const docmostClient = new DocmostClient(API_URL); @@ -544,6 +620,115 @@ server.registerTool( }, ); +// --- Comment Tools --- + +// Tool: list_comments +server.registerTool( + "list_comments", + { + description: + "List all comments on a page. Returns comments with content converted to Markdown.", + inputSchema: { + pageId: z.string().describe("ID of the page to list comments for"), + }, + }, + async ({ pageId }) => { + const comments = await docmostClient.listComments(pageId); + return jsonContent(comments); + }, +); + +// Tool: get_comment +server.registerTool( + "get_comment", + { + description: "Get a single comment by ID with content as Markdown.", + inputSchema: { + commentId: z.string().describe("ID of the comment"), + }, + }, + async ({ commentId }) => { + const comment = await docmostClient.getComment(commentId); + return jsonContent(comment); + }, +); + +// Tool: create_comment +server.registerTool( + "create_comment", + { + description: + "Create a new comment on a page. Content is provided as Markdown and automatically converted to the required format.", + inputSchema: { + pageId: z.string().describe("ID of the page to comment on"), + content: z.string().describe("Comment content in Markdown format"), + type: z + .enum(["page", "inline"]) + .optional() + .describe( + "Comment type: 'page' for general page comment (default), 'inline' for text selection comment", + ), + selection: z + .string() + .optional() + .describe( + "Selected text for inline comments (max 250 chars). Required when type is 'inline'.", + ), + parentCommentId: z + .string() + .optional() + .describe("Parent comment ID to create a reply (max 2 nesting levels)"), + }, + }, + async ({ pageId, content, type, selection, parentCommentId }) => { + const result = await docmostClient.createComment( + pageId, + content, + type || "page", + selection, + parentCommentId, + ); + return jsonContent(result); + }, +); + +// Tool: update_comment +server.registerTool( + "update_comment", + { + description: + "Update an existing comment's content. Only the comment creator can update it. Content is provided as Markdown.", + inputSchema: { + commentId: z.string().describe("ID of the comment to update"), + content: z.string().describe("New comment content in Markdown format"), + }, + }, + async ({ commentId, content }) => { + const result = await docmostClient.updateComment(commentId, content); + return jsonContent(result); + }, +); + +// Tool: delete_comment +server.registerTool( + "delete_comment", + { + description: + "Delete a comment. Only the comment creator or space admin can delete it.", + inputSchema: { + commentId: z.string().describe("ID of the comment to delete"), + }, + }, + async ({ commentId }) => { + await docmostClient.deleteComment(commentId); + return { + content: [ + { type: "text", text: `Successfully deleted comment ${commentId}` }, + ], + }; + }, +); + async function run() { const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/src/lib/filters.ts b/src/lib/filters.ts index 667fb43..d3816a0 100644 --- a/src/lib/filters.ts +++ b/src/lib/filters.ts @@ -60,6 +60,21 @@ export function filterPage(page: any, content?: string, subpages?: any[]) { }; } +export function filterComment(comment: any, markdownContent?: string) { + return { + id: comment.id, + pageId: comment.pageId, + content: markdownContent ?? comment.content, + selection: comment.selection || null, + type: comment.type || "page", + parentCommentId: comment.parentCommentId || null, + creatorId: comment.creatorId, + creatorName: comment.creator?.name || null, + createdAt: comment.createdAt, + editedAt: comment.editedAt || null, + }; +} + export function filterSearchResult(result: any) { return { id: result.id, diff --git a/src/lib/markdown-to-json.ts b/src/lib/markdown-to-json.ts new file mode 100644 index 0000000..d1cfda9 --- /dev/null +++ b/src/lib/markdown-to-json.ts @@ -0,0 +1,25 @@ +/** + * Convert Markdown text to TipTap/ProseMirror JSON format + * Used for creating and updating comments via Docmost API + */ +import { marked } from "marked"; +import { generateJSON } from "@tiptap/html"; +import { JSDOM } from "jsdom"; +import { tiptapExtensions } from "./tiptap-extensions.js"; + +// Ensure DOM environment is available (may already be set by collaboration.ts) +if (typeof global.document === "undefined") { + const dom = new JSDOM(""); + global.window = dom.window as any; + global.document = dom.window.document; + // @ts-ignore + global.Element = dom.window.Element; +} + +/** + * Convert markdown string to TipTap-compatible ProseMirror JSON + */ +export async function markdownToTiptapJson(markdown: string): Promise { + const html = await marked.parse(markdown); + return generateJSON(html, tiptapExtensions); +} -- 2.49.1 From 6cde1fddfc99e217fe8eb412359d22d983786a41 Mon Sep 17 00:00:00 2001 From: Max Nikitin Date: Tue, 10 Mar 2026 13:59:39 +0300 Subject: [PATCH 2/3] fix: stringify comment content and use cursor-based pagination Docmost comment API requires `content` as a JSON string, not a JSON object. Also, comments use cursor-based pagination (nextCursor/prevCursor) instead of page-based pagination used by other endpoints. Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 8198433..83ec704 100644 --- a/src/index.ts +++ b/src/index.ts @@ -334,8 +334,22 @@ class DocmostClient { // --- Comment Methods --- async listComments(pageId: string) { - const comments = await this.paginateAll("/comments", { pageId }); - return comments.map((comment: any) => { + await this.ensureAuthenticated(); + let allComments: any[] = []; + let cursor: string | null = null; + + do { + const payload: Record = { pageId, limit: 100 }; + if (cursor) payload.cursor = cursor; + + const response = await this.client.post("/comments", payload); + const data = response.data.data || response.data; + const items = data.items || []; + allComments = allComments.concat(items); + cursor = data.meta?.nextCursor || null; + } while (cursor); + + return allComments.map((comment: any) => { const markdown = comment.content ? convertProseMirrorToMarkdown(comment.content) : ""; @@ -367,7 +381,7 @@ class DocmostClient { const jsonContent = await markdownToTiptapJson(content); const payload: Record = { pageId, - content: jsonContent, + content: JSON.stringify(jsonContent), type, }; if (selection) payload.selection = selection; @@ -389,7 +403,7 @@ class DocmostClient { const jsonContent = await markdownToTiptapJson(content); const response = await this.client.post("/comments/update", { commentId, - content: jsonContent, + content: JSON.stringify(jsonContent), }); return { success: true, -- 2.49.1 From a8c8e0f40b541d6663780d389c8c7f20d006bc34 Mon Sep 17 00:00:00 2001 From: Max Nikitin Date: Tue, 10 Mar 2026 14:24:49 +0300 Subject: [PATCH 3/3] feat: add check_new_comments tool for efficient comment polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New tool that checks for comments created after a given timestamp. Scoping options: - By space (spaceId) — checks all pages in the space - By subtree (parentPageId) — checks only descendants of a page Uses updatedAt pre-filtering to minimize API calls: only pages updated after the target timestamp are checked for comments. Co-Authored-By: Claude Opus 4.6 --- README.md | 1 + src/index.ts | 118 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/README.md b/README.md index 8d82486..6cb0ac6 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ A Model Context Protocol (MCP) server for [Docmost](https://docmost.com/), enabl - **`create_comment`**: Create a page-level or inline comment. Content is provided as Markdown and automatically converted to ProseMirror JSON. Supports replies via `parentCommentId`. - **`update_comment`**: Update an existing comment's content (creator only). - **`delete_comment`**: Delete a comment (creator or space admin only). +- **`check_new_comments`**: Check for new comments across a space (or a page subtree) since a given timestamp. Efficiently filters by `updatedAt` before fetching comments. ### Technical Details diff --git a/src/index.ts b/src/index.ts index 83ec704..f7ac8e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -418,6 +418,91 @@ class DocmostClient { .post("/comments/delete", { commentId }) .then((res) => res.data); } + + /** + * Check for new comments across pages in a space (optionally scoped to a subtree). + * 1. Fetch all pages in space, filter by updatedAt > since + * 2. If parentPageId given, keep only descendants of that page + * 3. For matching pages, fetch comments and filter by createdAt > since + */ + async checkNewComments( + spaceId: string, + since: string, + parentPageId?: string, + ) { + await this.ensureAuthenticated(); + + const sinceDate = new Date(since); + + // 1. Get all pages in the space + const allPages = await this.paginateAll("/pages/recent", { spaceId }); + + // 2. If parentPageId specified, build set of descendant page IDs + let allowedPageIds: Set | null = null; + if (parentPageId) { + allowedPageIds = new Set(); + // BFS to collect all descendants + const pageMap = new Map(); + for (const page of allPages) { + const pid = page.parentPageId || "__root__"; + if (!pageMap.has(pid)) pageMap.set(pid, []); + pageMap.get(pid)!.push(page); + } + const queue = [parentPageId]; + allowedPageIds.add(parentPageId); + while (queue.length > 0) { + const current = queue.shift()!; + const children = pageMap.get(current) || []; + for (const child of children) { + allowedPageIds.add(child.id); + queue.push(child.id); + } + } + } + + // 3. Filter pages by updatedAt > since and optional subtree + const recentlyUpdated = allPages.filter((page: any) => { + if (new Date(page.updatedAt) <= sinceDate) return false; + if (allowedPageIds && !allowedPageIds.has(page.id)) return false; + return true; + }); + + // 4. Fetch comments for each updated page and filter by createdAt > since + const results: any[] = []; + for (const page of recentlyUpdated) { + try { + const comments = await this.listComments(page.id); + const newComments = comments.filter( + (c: any) => new Date(c.createdAt) > sinceDate, + ); + if (newComments.length > 0) { + results.push({ + pageId: page.id, + pageTitle: page.title, + comments: newComments, + }); + } + } catch (e: any) { + // Skip pages with errors (e.g. deleted between calls) + } + } + + const totalNewComments = results.reduce( + (sum, r) => sum + r.comments.length, + 0, + ); + + return { + since, + scope: parentPageId + ? `subtree of ${parentPageId}` + : `space ${spaceId}`, + checkedPages: recentlyUpdated.length, + pagesWithNewComments: results.length, + totalNewComments, + comments: results, + }; + } } const docmostClient = new DocmostClient(API_URL); @@ -743,6 +828,39 @@ server.registerTool( }, ); +// Tool: check_new_comments +server.registerTool( + "check_new_comments", + { + description: + "Check for new comments across pages in a space since a given timestamp. " + + "Optionally scope to a page subtree (folder). Returns only comments created after the specified time.", + inputSchema: { + spaceId: z.string().describe("Space ID to check for new comments"), + since: z + .string() + .describe( + "ISO 8601 timestamp — only return comments created after this time (e.g. '2026-03-10T00:00:00Z')", + ), + parentPageId: z + .string() + .optional() + .describe( + "Optional root page ID to scope the check to a subtree (folder). " + + "Only pages under this parent will be checked.", + ), + }, + }, + async ({ spaceId, since, parentPageId }) => { + const result = await docmostClient.checkNewComments( + spaceId, + since, + parentPageId, + ); + return jsonContent(result); + }, +); + async function run() { const transport = new StdioServerTransport(); await server.connect(transport); -- 2.49.1