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( endpoint: string, basePayload: Record = {}, limit: number = 100, ): Promise { 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 = { 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 = { 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("/pages/recent", { spaceId }); // 2. If parentPageId specified, build set of descendant page IDs let allowedPageIds: Set | null = null; if (parentPageId) { allowedPageIds = new Set(); 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); // --- 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); });