Adds 6 new MCP tools for managing Docmost page comments: - list_comments: list all comments on a page (content as Markdown) - get_comment: retrieve a single comment by ID - create_comment: create page-level or inline comment (Markdown input) - update_comment: update comment content (creator only) - delete_comment: delete a comment (creator or space admin) - check_new_comments: poll for new comments since a timestamp Implementation details: - New markdownToTiptapJson() utility in src/lib/markdown-to-json.ts - New filterComment() in src/lib/filters.ts - Supports inline comments, threaded replies via parentCommentId - README updated with comment tools documentation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
869 lines
24 KiB
TypeScript
869 lines
24 KiB
TypeScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
import FormData from "form-data";
|
|
import axios, { AxiosInstance } from "axios";
|
|
import { z } from "zod";
|
|
import {
|
|
filterWorkspace,
|
|
filterSpace,
|
|
filterGroup,
|
|
filterPage,
|
|
filterComment,
|
|
filterSearchResult,
|
|
} from "./lib/filters.js";
|
|
import { convertProseMirrorToMarkdown } from "./lib/markdown-converter.js";
|
|
import { markdownToTiptapJson } from "./lib/markdown-to-json.js";
|
|
import { readFileSync } from "fs";
|
|
import { fileURLToPath } from "url";
|
|
import { dirname, join } from "path";
|
|
import { updatePageContentRealtime } from "./lib/collaboration.js";
|
|
import { getCollabToken, performLogin } from "./lib/auth-utils.js";
|
|
|
|
// Read version from package.json
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
const packageJson = JSON.parse(
|
|
readFileSync(join(__dirname, "../package.json"), "utf-8"),
|
|
);
|
|
const VERSION = packageJson.version;
|
|
|
|
const API_URL = process.env.DOCMOST_API_URL;
|
|
const EMAIL = process.env.DOCMOST_EMAIL;
|
|
const PASSWORD = process.env.DOCMOST_PASSWORD;
|
|
|
|
if (!API_URL || !EMAIL || !PASSWORD) {
|
|
console.error(
|
|
"Error: DOCMOST_API_URL, DOCMOST_EMAIL, and DOCMOST_PASSWORD environment variables are required.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
class DocmostClient {
|
|
// ... [Client Implementation stays exactly the same] ...
|
|
private client: AxiosInstance;
|
|
private token: string | null = null;
|
|
|
|
constructor(baseURL: string) {
|
|
this.client = axios.create({
|
|
baseURL,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
}
|
|
|
|
async login() {
|
|
if (!EMAIL || !PASSWORD) {
|
|
throw new Error("Missing Credentials (DOCMOST_EMAIL, DOCMOST_PASSWORD)");
|
|
}
|
|
// baseURL is already set in this.client
|
|
const baseURL = this.client.defaults.baseURL || "";
|
|
|
|
// Use shared auth utility
|
|
this.token = await performLogin(baseURL, EMAIL, PASSWORD);
|
|
this.client.defaults.headers.common["Authorization"] =
|
|
`Bearer ${this.token}`;
|
|
}
|
|
|
|
async ensureAuthenticated() {
|
|
if (!this.token) {
|
|
await this.login();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generic pagination handler for Docmost API endpoints
|
|
* @param endpoint - The API endpoint path (e.g., "/spaces", "/pages/recent")
|
|
* @param basePayload - Base payload object to send with each request
|
|
* @param limit - Items per page (min: 1, max: 100, default: 100)
|
|
* @returns All items collected from all pages
|
|
*/
|
|
async paginateAll<T = any>(
|
|
endpoint: string,
|
|
basePayload: Record<string, any> = {},
|
|
limit: number = 100,
|
|
): Promise<T[]> {
|
|
await this.ensureAuthenticated();
|
|
|
|
// Clamp limit between 1 and 100
|
|
const clampedLimit = Math.max(1, Math.min(100, limit));
|
|
|
|
let page = 1;
|
|
let allItems: T[] = [];
|
|
let hasNextPage = true;
|
|
|
|
while (hasNextPage) {
|
|
const response = await this.client.post(endpoint, {
|
|
...basePayload,
|
|
limit: clampedLimit,
|
|
page,
|
|
});
|
|
|
|
const data = response.data;
|
|
|
|
// Handle both direct data.items and data.data.items structures
|
|
const items = data.data?.items || data.items || [];
|
|
const meta = data.data?.meta || data.meta;
|
|
|
|
allItems = allItems.concat(items);
|
|
hasNextPage = meta?.hasNextPage || false;
|
|
page++;
|
|
}
|
|
|
|
return allItems;
|
|
}
|
|
|
|
async getWorkspace() {
|
|
await this.ensureAuthenticated();
|
|
const response = await this.client.post("/workspace/info", {});
|
|
return {
|
|
data: filterWorkspace(response.data.data),
|
|
success: response.data.success,
|
|
};
|
|
}
|
|
|
|
async getSpaces() {
|
|
const spaces = await this.paginateAll("/spaces", {});
|
|
return spaces.map((space) => filterSpace(space));
|
|
}
|
|
|
|
async getGroups() {
|
|
const groups = await this.paginateAll("/groups", {});
|
|
return groups.map((group) => filterGroup(group));
|
|
}
|
|
|
|
async listPages(spaceId?: string) {
|
|
const payload = spaceId ? { spaceId } : {};
|
|
const pages = await this.paginateAll("/pages/recent", payload);
|
|
return pages.map((page) => filterPage(page));
|
|
}
|
|
|
|
async listSidebarPages(spaceId: string, pageId: string) {
|
|
await this.ensureAuthenticated();
|
|
const response = await this.client.post("/pages/sidebar-pages", {
|
|
spaceId,
|
|
pageId,
|
|
page: 1,
|
|
});
|
|
return response.data?.data?.items || [];
|
|
}
|
|
|
|
async getPage(pageId: string) {
|
|
await this.ensureAuthenticated();
|
|
const response = await this.client.post("/pages/info", { pageId });
|
|
const resultData = response.data.data; // Assuming data is nested under 'data'
|
|
|
|
let content = resultData.content
|
|
? convertProseMirrorToMarkdown(resultData.content)
|
|
: ""; // Default to empty string
|
|
|
|
// Always fetch subpages to provide context to the agent
|
|
let subpages: any[] = [];
|
|
|
|
try {
|
|
subpages = await this.listSidebarPages(resultData.spaceId, pageId);
|
|
} catch (e: any) {
|
|
console.warn("Failed to fetch subpages:", e);
|
|
}
|
|
|
|
// Resolve subpages if the placeholder exists
|
|
if (content && content.includes("{{SUBPAGES}}")) {
|
|
if (subpages && subpages.length > 0) {
|
|
const list = subpages
|
|
.map((p: any) => `- [${p.title}](page:${p.id})`)
|
|
.join("\n");
|
|
content = content.replace("{{SUBPAGES}}", `### Subpages\n${list}`);
|
|
} else {
|
|
content = content.replace("{{SUBPAGES}}", "");
|
|
}
|
|
}
|
|
|
|
return {
|
|
data: filterPage(resultData, content, subpages),
|
|
success: response.data.success,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a new page with title and content.
|
|
*
|
|
* Note: As long as Docmost doesn't provide a /pages/create endpoint that allows
|
|
* setting content directly, we must use the /pages/import workaround to create
|
|
* pages with initial content. This method:
|
|
* 1. Creates the page via /pages/import (which supports content)
|
|
* 2. Moves it to the correct parent if specified
|
|
*/
|
|
async createPage(
|
|
title: string,
|
|
content: string,
|
|
spaceId: string,
|
|
parentPageId?: string,
|
|
) {
|
|
await this.ensureAuthenticated();
|
|
|
|
if (parentPageId) {
|
|
try {
|
|
await this.getPage(parentPageId);
|
|
} catch (e) {
|
|
throw new Error(`Parent page with ID ${parentPageId} not found.`);
|
|
}
|
|
}
|
|
|
|
// 1. Create content via Import (using multipart/form-data)
|
|
const form = new FormData();
|
|
form.append("spaceId", spaceId);
|
|
|
|
const fileContent = Buffer.from(content, "utf-8");
|
|
form.append("file", fileContent, {
|
|
filename: `${title || "import"}.md`,
|
|
contentType: "text/markdown",
|
|
});
|
|
|
|
const headers = {
|
|
...form.getHeaders(),
|
|
Authorization: `Bearer ${this.token}`,
|
|
};
|
|
|
|
// Use raw axios call for FormData handling
|
|
const response = await axios.post(`${API_URL}/pages/import`, form, {
|
|
headers,
|
|
});
|
|
const newPageId = response.data.data.id;
|
|
|
|
// 2. Move to parent if needed
|
|
if (parentPageId) {
|
|
await this.movePage(newPageId, parentPageId);
|
|
}
|
|
|
|
// Return the final page object
|
|
return this.getPage(newPageId);
|
|
}
|
|
|
|
/**
|
|
* Update a page's content and optionally its title.
|
|
* Leverages WebSocket collaboration to update content without changing Page ID.
|
|
*/
|
|
async updatePage(pageId: string, content: string, title?: string) {
|
|
await this.ensureAuthenticated();
|
|
|
|
// 1. Update Title via REST API if provided
|
|
if (title) {
|
|
await this.client.post("/pages/update", { pageId, title });
|
|
}
|
|
|
|
// 2. Update Content via WebSocket
|
|
let collabToken = "";
|
|
try {
|
|
const baseURL = this.client.defaults.baseURL || "";
|
|
collabToken = await getCollabToken(baseURL, this.token!);
|
|
await updatePageContentRealtime(pageId, content, collabToken, baseURL);
|
|
} catch (error: any) {
|
|
console.error(
|
|
"Failed to update page content via realtime collaboration:",
|
|
error,
|
|
);
|
|
const tokenPreview = collabToken
|
|
? collabToken.substring(0, 15) + "..."
|
|
: "null";
|
|
throw new Error(
|
|
`Failed to update page content: ${error.message} (Token: ${tokenPreview})`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
modified: true,
|
|
message: "Page updated successfully.",
|
|
pageId: pageId,
|
|
};
|
|
}
|
|
|
|
async search(query: string, spaceId?: string) {
|
|
await this.ensureAuthenticated();
|
|
const response = await this.client.post("/search", {
|
|
query,
|
|
spaceId,
|
|
});
|
|
|
|
// Normalize search response for Docmost 0.25+ compatibility
|
|
// Before 0.25: response.data.data was a direct array
|
|
// After 0.25: response.data.data is { items: [...], meta: {...} }
|
|
const data = response.data?.data;
|
|
const items = Array.isArray(data) ? data : (data?.items || []);
|
|
const filteredItems = items.map((item: any) => filterSearchResult(item));
|
|
|
|
return {
|
|
items: filteredItems,
|
|
success: response.data?.success || false,
|
|
};
|
|
}
|
|
|
|
async movePage(
|
|
pageId: string,
|
|
parentPageId: string | null,
|
|
position?: string,
|
|
) {
|
|
await this.ensureAuthenticated();
|
|
// Docmost requires position >= 5 chars.
|
|
const validPosition = position || "a00000";
|
|
|
|
return this.client
|
|
.post("/pages/move", {
|
|
pageId,
|
|
parentPageId,
|
|
position: validPosition,
|
|
})
|
|
.then((res) => res.data);
|
|
}
|
|
|
|
async deletePage(pageId: string) {
|
|
await this.ensureAuthenticated();
|
|
return this.client
|
|
.post("/pages/delete", { pageId })
|
|
.then((res) => res.data);
|
|
}
|
|
|
|
async deletePages(pageIds: string[]) {
|
|
await this.ensureAuthenticated();
|
|
const promises = pageIds.map((id) =>
|
|
this.client
|
|
.post("/pages/delete", { pageId: id })
|
|
.then(() => ({ id, success: true }))
|
|
.catch((err: any) => ({ id, success: false, error: err.message })),
|
|
);
|
|
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);
|
|
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>();
|
|
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);
|
|
|
|
// --- Modern McpServer Implementation ---
|
|
|
|
const server = new McpServer({
|
|
name: "docmost-mcp",
|
|
version: VERSION,
|
|
});
|
|
|
|
// Helper to format JSON responses
|
|
const jsonContent = (data: any) => ({
|
|
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
|
|
});
|
|
|
|
// Tool: list_workspaces
|
|
server.registerTool(
|
|
"get_workspace",
|
|
{
|
|
description: "Get the current Docmost workspace",
|
|
},
|
|
async () => {
|
|
const workspace = await docmostClient.getWorkspace();
|
|
return jsonContent(workspace);
|
|
},
|
|
);
|
|
|
|
// Tool: list_spaces
|
|
server.registerTool(
|
|
"list_spaces",
|
|
{
|
|
description: "List all available spaces in Docmost",
|
|
},
|
|
async () => {
|
|
const spaces = await docmostClient.getSpaces();
|
|
return jsonContent(spaces);
|
|
},
|
|
);
|
|
|
|
// Tool: list_groups
|
|
server.registerTool(
|
|
"list_groups",
|
|
{
|
|
description: "List all available groups in Docmost",
|
|
},
|
|
async () => {
|
|
const groups = await docmostClient.getGroups();
|
|
return jsonContent(groups);
|
|
},
|
|
);
|
|
|
|
// Tool: list_pages
|
|
server.registerTool(
|
|
"list_pages",
|
|
{
|
|
description: "List pages in a space ordered by updatedAt (descending).",
|
|
inputSchema: {
|
|
spaceId: z.string().optional(),
|
|
},
|
|
},
|
|
async ({ spaceId }) => {
|
|
const result = await docmostClient.listPages(spaceId);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: get_page
|
|
server.registerTool(
|
|
"get_page",
|
|
{
|
|
description: "Get details and content of a specific page by ID",
|
|
inputSchema: {
|
|
pageId: z.string(),
|
|
},
|
|
},
|
|
async ({ pageId }) => {
|
|
const page = await docmostClient.getPage(pageId);
|
|
return jsonContent(page);
|
|
},
|
|
);
|
|
|
|
// Tool: create_page (Smart)
|
|
server.registerTool(
|
|
"create_page",
|
|
{
|
|
description:
|
|
"Create a new page with content (automatically moves it to the correct hierarchy).",
|
|
inputSchema: {
|
|
title: z.string().describe("Title of the page"),
|
|
content: z.string().describe("Markdown content"),
|
|
spaceId: z.string(),
|
|
parentPageId: z
|
|
.string()
|
|
.optional()
|
|
.describe("Optional parent page ID to nest under"),
|
|
},
|
|
},
|
|
async ({ title, content, spaceId, parentPageId }) => {
|
|
const result = await docmostClient.createPage(
|
|
title,
|
|
content,
|
|
spaceId,
|
|
parentPageId,
|
|
);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: update_page (Safe)
|
|
server.registerTool(
|
|
"update_page",
|
|
{
|
|
description:
|
|
"Update a page's content and/or title via realtime collaboration (preserves Page ID and history).",
|
|
inputSchema: {
|
|
pageId: z.string().describe("ID of the page to update"),
|
|
content: z.string().describe("New Markdown content"),
|
|
title: z.string().optional().describe("Optional new title"),
|
|
},
|
|
},
|
|
async ({ pageId, content, title }) => {
|
|
const result = await docmostClient.updatePage(pageId, content, title);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: move_page
|
|
server.registerTool(
|
|
"move_page",
|
|
{
|
|
description:
|
|
"Move a page to a new parent (nesting) or root. Essential for organizing pages created via 'import_page'.",
|
|
inputSchema: {
|
|
pageId: z.string(),
|
|
parentPageId: z
|
|
.string()
|
|
.nullable()
|
|
.optional()
|
|
.describe(
|
|
"Target parent page ID. Pass 'null' or empty string to move to root.",
|
|
),
|
|
position: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Optional position string (5-12 chars). Defaults to 'a00000' (end) if omitted.",
|
|
),
|
|
},
|
|
},
|
|
async ({ pageId, parentPageId, position }) => {
|
|
// Ensure parentPageId is null if string "null" or empty is passed, or undefined
|
|
// Note: Zod handles type checking, but we double check for empty strings just in case
|
|
const finalParentId =
|
|
parentPageId === "" || parentPageId === "null" ? null : parentPageId;
|
|
|
|
await docmostClient.movePage(pageId, finalParentId || null, position);
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Successfully moved page ${pageId} to parent ${finalParentId || "root"}`,
|
|
},
|
|
],
|
|
};
|
|
},
|
|
);
|
|
|
|
// Tool: delete_page
|
|
server.registerTool(
|
|
"delete_page",
|
|
{
|
|
description: "Delete a single page by ID.",
|
|
inputSchema: {
|
|
pageId: z.string(),
|
|
},
|
|
},
|
|
async ({ pageId }) => {
|
|
await docmostClient.deletePage(pageId);
|
|
return {
|
|
content: [{ type: "text", text: `Successfully deleted page ${pageId}` }],
|
|
};
|
|
},
|
|
);
|
|
|
|
// Tool: delete_pages
|
|
server.registerTool(
|
|
"delete_pages",
|
|
{
|
|
description: "Delete multiple pages at once. Useful for cleanup.",
|
|
inputSchema: {
|
|
pageIds: z.array(z.string()),
|
|
},
|
|
},
|
|
async ({ pageIds }) => {
|
|
const results = await docmostClient.deletePages(pageIds);
|
|
return jsonContent(results);
|
|
},
|
|
);
|
|
|
|
// Tool: search
|
|
server.registerTool(
|
|
"search",
|
|
{
|
|
description: "Search for pages and content.",
|
|
inputSchema: {
|
|
query: z.string().describe("Search query"),
|
|
spaceId: z.string().optional().describe("Optional space ID to filter by"),
|
|
},
|
|
},
|
|
async ({ query, spaceId }) => {
|
|
const result = await docmostClient.search(query, spaceId);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// --- 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);
|
|
}
|
|
|
|
run().catch((error) => {
|
|
console.error("Fatal error running server:", error);
|
|
process.exit(1);
|
|
});
|