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 <noreply@anthropic.com>
This commit is contained in:
Max Nikitin
2026-03-10 12:33:39 +03:00
parent 493ce01254
commit a739d62318
4 changed files with 233 additions and 0 deletions

View File

@@ -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<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);
@@ -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);