feat: add comment support (list, get, create, update, delete) #3
@@ -20,6 +20,15 @@ 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).
|
||||
- **`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
|
||||
|
||||
- **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.
|
||||
|
||||
317
src/index.ts
317
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,179 @@ class DocmostClient {
|
||||
);
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
// --- Comment Methods ---
|
||||
|
||||
async listComments(pageId: string) {
|
||||
await this.ensureAuthenticated();
|
||||
let allComments: any[] = [];
|
||||
let cursor: string | null = null;
|
||||
|
||||
do {
|
||||
const payload: Record<string, any> = { 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)
|
||||
: "";
|
||||
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<string, any> = {
|
||||
pageId,
|
||||
content: JSON.stringify(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: JSON.stringify(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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<any>("/pages/recent", { spaceId });
|
||||
|
||||
// 2. If parentPageId specified, build set of descendant page IDs
|
||||
let allowedPageIds: Set<string> | null = null;
|
||||
if (parentPageId) {
|
||||
allowedPageIds = new Set<string>();
|
||||
// BFS to collect all descendants
|
||||
const pageMap = new Map<string, any[]>();
|
||||
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);
|
||||
@@ -544,6 +719,148 @@ 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}` },
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
25
src/lib/markdown-to-json.ts
Normal file
25
src/lib/markdown-to-json.ts
Normal file
@@ -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("<!DOCTYPE html><html><body></body></html>");
|
||||
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<any> {
|
||||
const html = await marked.parse(markdown);
|
||||
return generateJSON(html, tiptapExtensions);
|
||||
}
|
||||
Reference in New Issue
Block a user