feat: add comment support (list, get, create, update, delete) #3

Closed
ITSalt wants to merge 3 commits from ITSalt/feat/comment-support into main
4 changed files with 233 additions and 0 deletions
Showing only changes of commit a739d62318 - Show all commits

View File

@@ -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). - **`list_pages`**: List pages within a space (ordered by `updatedAt` descending).
- **`get_page`**: Retrieve full content and metadata of a specific page. - **`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 ### 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. - **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.

View File

@@ -8,6 +8,7 @@ import {
filterSpace, filterSpace,
filterGroup, filterGroup,
filterPage, filterPage,
filterComment,
filterSearchResult, filterSearchResult,
} from "./lib/filters.js"; } from "./lib/filters.js";
import { convertProseMirrorToMarkdown } from "./lib/markdown-converter.js"; import { convertProseMirrorToMarkdown } from "./lib/markdown-converter.js";
@@ -16,6 +17,7 @@ import { fileURLToPath } from "url";
import { dirname, join } from "path"; import { dirname, join } from "path";
import { updatePageContentRealtime } from "./lib/collaboration.js"; import { updatePageContentRealtime } from "./lib/collaboration.js";
import { getCollabToken, performLogin } from "./lib/auth-utils.js"; import { getCollabToken, performLogin } from "./lib/auth-utils.js";
import { markdownToTiptapJson } from "./lib/markdown-to-json.js";
// Read version from package.json // Read version from package.json
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -328,6 +330,80 @@ class DocmostClient {
); );
return Promise.all(promises); 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<string, any> = {
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); 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() { async function run() {
const transport = new StdioServerTransport(); const transport = new StdioServerTransport();
await server.connect(transport); await server.connect(transport);

View File

@@ -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) { export function filterSearchResult(result: any) {
return { return {
id: result.id, id: result.id,

View 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);
}