docmost-mcp 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Moritz Krause
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # Docmost MCP Server
2
+
3
+ A Model Context Protocol (MCP) server for [Docmost](https://docmost.com/), enabling AI agents to search, create, modify, and organize documentation pages and spaces.
4
+
5
+ ## Features
6
+
7
+ ### Core Management
8
+
9
+ - **`create_page`**: Smart creation tool. Creates content (via import) AND handles hierarchy (nesting under a parent) in one go.
10
+ - **`update_page`**: Update a page's content and/or title. Updates are performed via real-time collaboration (WebSocket).
11
+ - **`delete_page` / `delete_pages`**: Delete single or multiple pages at once.
12
+ - **`move_page`**: Organize pages hierarchically by moving them to a new parent or root.
13
+
14
+ ### Exploration & Retrieval
15
+
16
+ - **`search`**: Full-text search across spaces with optional space filtering (`query`, `spaceId`).
17
+ - **`get_workspace`**: Get information about the current Docmost workspace.
18
+ - **`list_spaces`**: View all spaces within the current workspace.
19
+ - **`list_groups`**: View all groups within the current workspace.
20
+ - **`list_pages`**: List pages within a space (ordered by `updatedAt` descending).
21
+ - **`get_page`**: Retrieve full content and metadata of a specific page.
22
+
23
+ ### Technical Details
24
+
25
+ - **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.
26
+ - **Smart Import API**: Uses Docmost's import API to ensure clean Markdown-to-ProseMirror conversion when creating pages.
27
+ - **Child Preservation**: The `update_page` tool creates a new page ID but effectively simulates an in-place update by reparenting existing child pages to the new version.
28
+ - **Pagination Support**: Automatically handles pagination for large datasets (spaces, pages, groups).
29
+ - **Filtered Responses**: API responses are filtered to include only relevant information, optimizing data transfer for agents.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ npm install
35
+ npm run build
36
+ ```
37
+
38
+ ## Configuration
39
+
40
+ This server requires the following environment variables to be set:
41
+
42
+ - `DOCMOST_API_URL`: The full URL to your Docmost API (e.g., `https://docs.example.com/api`).
43
+ - `DOCMOST_EMAIL`: The email address for authentication.
44
+ - `DOCMOST_PASSWORD`: The password for authentication.
45
+
46
+ ## usage with Claude Desktop / generic MCP Client
47
+
48
+ Add the following to your MCP configuration (e.g. `claude_desktop_config.json`):
49
+
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "docmost-local": {
54
+ "command": "node",
55
+ "args": ["./build/index.js"],
56
+ "env": {
57
+ "DOCMOST_API_URL": "http://localhost:3000/api",
58
+ "DOCMOST_EMAIL": "test@docmost.com",
59
+ "DOCMOST_PASSWORD": "test"
60
+ }
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ ## Development
67
+
68
+ ```bash
69
+ # Watch mode
70
+ npm run watch
71
+
72
+ # Build
73
+ npm run build
74
+ ```
75
+
76
+ ## License
77
+
78
+ MIT
package/build/index.js ADDED
@@ -0,0 +1,409 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import FormData from "form-data";
5
+ import axios from "axios";
6
+ import { z } from "zod";
7
+ import { filterWorkspace, filterSpace, filterGroup, filterPage, filterSearchResult, } from "./lib/filters.js";
8
+ import { convertProseMirrorToMarkdown } from "./lib/markdown-converter.js";
9
+ import { readFileSync } from "fs";
10
+ import { fileURLToPath } from "url";
11
+ import { dirname, join } from "path";
12
+ import { updatePageContentRealtime } from "./lib/collaboration.js";
13
+ import { getCollabToken, performLogin } from "./lib/auth-utils.js";
14
+ // Read version from package.json
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+ const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
18
+ const VERSION = packageJson.version;
19
+ const API_URL = process.env.DOCMOST_API_URL;
20
+ const EMAIL = process.env.DOCMOST_EMAIL;
21
+ const PASSWORD = process.env.DOCMOST_PASSWORD;
22
+ if (!API_URL || !EMAIL || !PASSWORD) {
23
+ console.error("Error: DOCMOST_API_URL, DOCMOST_EMAIL, and DOCMOST_PASSWORD environment variables are required.");
24
+ process.exit(1);
25
+ }
26
+ class DocmostClient {
27
+ // ... [Client Implementation stays exactly the same] ...
28
+ client;
29
+ token = null;
30
+ constructor(baseURL) {
31
+ this.client = axios.create({
32
+ baseURL,
33
+ headers: {
34
+ "Content-Type": "application/json",
35
+ },
36
+ });
37
+ }
38
+ async login() {
39
+ if (!EMAIL || !PASSWORD) {
40
+ throw new Error("Missing Credentials (DOCMOST_EMAIL, DOCMOST_PASSWORD)");
41
+ }
42
+ // baseURL is already set in this.client
43
+ const baseURL = this.client.defaults.baseURL || "";
44
+ // Use shared auth utility
45
+ this.token = await performLogin(baseURL, EMAIL, PASSWORD);
46
+ this.client.defaults.headers.common["Authorization"] =
47
+ `Bearer ${this.token}`;
48
+ }
49
+ async ensureAuthenticated() {
50
+ if (!this.token) {
51
+ await this.login();
52
+ }
53
+ }
54
+ /**
55
+ * Generic pagination handler for Docmost API endpoints
56
+ * @param endpoint - The API endpoint path (e.g., "/spaces", "/pages/recent")
57
+ * @param basePayload - Base payload object to send with each request
58
+ * @param limit - Items per page (min: 1, max: 100, default: 100)
59
+ * @returns All items collected from all pages
60
+ */
61
+ async paginateAll(endpoint, basePayload = {}, limit = 100) {
62
+ await this.ensureAuthenticated();
63
+ // Clamp limit between 1 and 100
64
+ const clampedLimit = Math.max(1, Math.min(100, limit));
65
+ let page = 1;
66
+ let allItems = [];
67
+ let hasNextPage = true;
68
+ while (hasNextPage) {
69
+ const response = await this.client.post(endpoint, {
70
+ ...basePayload,
71
+ limit: clampedLimit,
72
+ page,
73
+ });
74
+ const data = response.data;
75
+ // Handle both direct data.items and data.data.items structures
76
+ const items = data.data?.items || data.items || [];
77
+ const meta = data.data?.meta || data.meta;
78
+ allItems = allItems.concat(items);
79
+ hasNextPage = meta?.hasNextPage || false;
80
+ page++;
81
+ }
82
+ return allItems;
83
+ }
84
+ async getWorkspace() {
85
+ await this.ensureAuthenticated();
86
+ const response = await this.client.post("/workspace/info", {});
87
+ return {
88
+ data: filterWorkspace(response.data.data),
89
+ success: response.data.success,
90
+ };
91
+ }
92
+ async getSpaces() {
93
+ const spaces = await this.paginateAll("/spaces", {});
94
+ return spaces.map((space) => filterSpace(space));
95
+ }
96
+ async getGroups() {
97
+ const groups = await this.paginateAll("/groups", {});
98
+ return groups.map((group) => filterGroup(group));
99
+ }
100
+ async listPages(spaceId) {
101
+ const payload = spaceId ? { spaceId } : {};
102
+ const pages = await this.paginateAll("/pages/recent", payload);
103
+ return pages.map((page) => filterPage(page));
104
+ }
105
+ async listSidebarPages(spaceId, pageId) {
106
+ await this.ensureAuthenticated();
107
+ const response = await this.client.post("/pages/sidebar-pages", {
108
+ spaceId,
109
+ pageId,
110
+ page: 1,
111
+ });
112
+ return response.data?.data?.items || [];
113
+ }
114
+ async getPage(pageId) {
115
+ await this.ensureAuthenticated();
116
+ const response = await this.client.post("/pages/info", { pageId });
117
+ const resultData = response.data.data; // Assuming data is nested under 'data'
118
+ let content = resultData.content
119
+ ? convertProseMirrorToMarkdown(resultData.content)
120
+ : ""; // Default to empty string
121
+ // Always fetch subpages to provide context to the agent
122
+ let subpages = [];
123
+ try {
124
+ subpages = await this.listSidebarPages(resultData.spaceId, pageId);
125
+ }
126
+ catch (e) {
127
+ console.warn("Failed to fetch subpages:", e);
128
+ }
129
+ // Resolve subpages if the placeholder exists
130
+ if (content && content.includes("{{SUBPAGES}}")) {
131
+ if (subpages && subpages.length > 0) {
132
+ const list = subpages
133
+ .map((p) => `- [${p.title}](page:${p.id})`)
134
+ .join("\n");
135
+ content = content.replace("{{SUBPAGES}}", `### Subpages\n${list}`);
136
+ }
137
+ else {
138
+ content = content.replace("{{SUBPAGES}}", "");
139
+ }
140
+ }
141
+ return {
142
+ data: filterPage(resultData, content, subpages),
143
+ success: response.data.success,
144
+ };
145
+ }
146
+ /**
147
+ * Create a new page with title and content.
148
+ *
149
+ * Note: As long as Docmost doesn't provide a /pages/create endpoint that allows
150
+ * setting content directly, we must use the /pages/import workaround to create
151
+ * pages with initial content. This method:
152
+ * 1. Creates the page via /pages/import (which supports content)
153
+ * 2. Moves it to the correct parent if specified
154
+ */
155
+ async createPage(title, content, spaceId, parentPageId) {
156
+ await this.ensureAuthenticated();
157
+ if (parentPageId) {
158
+ try {
159
+ await this.getPage(parentPageId);
160
+ }
161
+ catch (e) {
162
+ throw new Error(`Parent page with ID ${parentPageId} not found.`);
163
+ }
164
+ }
165
+ // 1. Create content via Import (using multipart/form-data)
166
+ const form = new FormData();
167
+ form.append("spaceId", spaceId);
168
+ const fileContent = Buffer.from(content, "utf-8");
169
+ form.append("file", fileContent, {
170
+ filename: `${title || "import"}.md`,
171
+ contentType: "text/markdown",
172
+ });
173
+ const headers = {
174
+ ...form.getHeaders(),
175
+ Authorization: `Bearer ${this.token}`,
176
+ };
177
+ // Use raw axios call for FormData handling
178
+ const response = await axios.post(`${API_URL}/pages/import`, form, {
179
+ headers,
180
+ });
181
+ const newPageId = response.data.data.id;
182
+ // 2. Move to parent if needed
183
+ if (parentPageId) {
184
+ await this.movePage(newPageId, parentPageId);
185
+ }
186
+ // Return the final page object
187
+ return this.getPage(newPageId);
188
+ }
189
+ /**
190
+ * Update a page's content and optionally its title.
191
+ * Leverages WebSocket collaboration to update content without changing Page ID.
192
+ */
193
+ async updatePage(pageId, content, title) {
194
+ await this.ensureAuthenticated();
195
+ // 1. Update Title via REST API if provided
196
+ if (title) {
197
+ await this.client.post("/pages/update", { pageId, title });
198
+ }
199
+ // 2. Update Content via WebSocket
200
+ let collabToken = "";
201
+ try {
202
+ const baseURL = this.client.defaults.baseURL || "";
203
+ collabToken = await getCollabToken(baseURL, this.token);
204
+ await updatePageContentRealtime(pageId, content, collabToken, baseURL);
205
+ }
206
+ catch (error) {
207
+ console.error("Failed to update page content via realtime collaboration:", error);
208
+ const tokenPreview = collabToken
209
+ ? collabToken.substring(0, 15) + "..."
210
+ : "null";
211
+ throw new Error(`Failed to update page content: ${error.message} (Token: ${tokenPreview})`);
212
+ }
213
+ return {
214
+ success: true,
215
+ modified: true,
216
+ message: "Page updated successfully.",
217
+ pageId: pageId,
218
+ };
219
+ }
220
+ async search(query, spaceId) {
221
+ await this.ensureAuthenticated();
222
+ const response = await this.client.post("/search", {
223
+ query,
224
+ spaceId,
225
+ });
226
+ // Filter search results (data is directly an array)
227
+ const items = response.data?.data || [];
228
+ const filteredItems = items.map((item) => filterSearchResult(item));
229
+ return {
230
+ items: filteredItems,
231
+ success: response.data?.success || false,
232
+ };
233
+ }
234
+ async movePage(pageId, parentPageId, position) {
235
+ await this.ensureAuthenticated();
236
+ // Docmost requires position >= 5 chars.
237
+ const validPosition = position || "a00000";
238
+ return this.client
239
+ .post("/pages/move", {
240
+ pageId,
241
+ parentPageId,
242
+ position: validPosition,
243
+ })
244
+ .then((res) => res.data);
245
+ }
246
+ async deletePage(pageId) {
247
+ await this.ensureAuthenticated();
248
+ return this.client
249
+ .post("/pages/delete", { pageId })
250
+ .then((res) => res.data);
251
+ }
252
+ async deletePages(pageIds) {
253
+ await this.ensureAuthenticated();
254
+ const promises = pageIds.map((id) => this.client
255
+ .post("/pages/delete", { pageId: id })
256
+ .then(() => ({ id, success: true }))
257
+ .catch((err) => ({ id, success: false, error: err.message })));
258
+ return Promise.all(promises);
259
+ }
260
+ }
261
+ const docmostClient = new DocmostClient(API_URL);
262
+ // --- Modern McpServer Implementation ---
263
+ const server = new McpServer({
264
+ name: "docmost-mcp",
265
+ version: VERSION,
266
+ });
267
+ // Helper to format JSON responses
268
+ const jsonContent = (data) => ({
269
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
270
+ });
271
+ // Tool: list_workspaces
272
+ server.registerTool("get_workspace", {
273
+ description: "Get the current Docmost workspace",
274
+ }, async () => {
275
+ const workspace = await docmostClient.getWorkspace();
276
+ return jsonContent(workspace);
277
+ });
278
+ // Tool: list_spaces
279
+ server.registerTool("list_spaces", {
280
+ description: "List all available spaces in Docmost",
281
+ }, async () => {
282
+ const spaces = await docmostClient.getSpaces();
283
+ return jsonContent(spaces);
284
+ });
285
+ // Tool: list_groups
286
+ server.registerTool("list_groups", {
287
+ description: "List all available groups in Docmost",
288
+ }, async () => {
289
+ const groups = await docmostClient.getGroups();
290
+ return jsonContent(groups);
291
+ });
292
+ // Tool: list_pages
293
+ server.registerTool("list_pages", {
294
+ description: "List pages in a space ordered by updatedAt (descending).",
295
+ inputSchema: {
296
+ spaceId: z.string().optional(),
297
+ },
298
+ }, async ({ spaceId }) => {
299
+ const result = await docmostClient.listPages(spaceId);
300
+ return jsonContent(result);
301
+ });
302
+ // Tool: get_page
303
+ server.registerTool("get_page", {
304
+ description: "Get details and content of a specific page by ID",
305
+ inputSchema: {
306
+ pageId: z.string(),
307
+ },
308
+ }, async ({ pageId }) => {
309
+ const page = await docmostClient.getPage(pageId);
310
+ return jsonContent(page);
311
+ });
312
+ // Tool: create_page (Smart)
313
+ server.registerTool("create_page", {
314
+ description: "Create a new page with content (automatically moves it to the correct hierarchy).",
315
+ inputSchema: {
316
+ title: z.string().describe("Title of the page"),
317
+ content: z.string().describe("Markdown content"),
318
+ spaceId: z.string(),
319
+ parentPageId: z
320
+ .string()
321
+ .optional()
322
+ .describe("Optional parent page ID to nest under"),
323
+ },
324
+ }, async ({ title, content, spaceId, parentPageId }) => {
325
+ const result = await docmostClient.createPage(title, content, spaceId, parentPageId);
326
+ return jsonContent(result);
327
+ });
328
+ // Tool: update_page (Safe)
329
+ server.registerTool("update_page", {
330
+ description: "Update a page's content and/or title via realtime collaboration (preserves Page ID and history).",
331
+ inputSchema: {
332
+ pageId: z.string().describe("ID of the page to update"),
333
+ content: z.string().describe("New Markdown content"),
334
+ title: z.string().optional().describe("Optional new title"),
335
+ },
336
+ }, async ({ pageId, content, title }) => {
337
+ const result = await docmostClient.updatePage(pageId, content, title);
338
+ return jsonContent(result);
339
+ });
340
+ // Tool: move_page
341
+ server.registerTool("move_page", {
342
+ description: "Move a page to a new parent (nesting) or root. Essential for organizing pages created via 'import_page'.",
343
+ inputSchema: {
344
+ pageId: z.string(),
345
+ parentPageId: z
346
+ .string()
347
+ .nullable()
348
+ .optional()
349
+ .describe("Target parent page ID. Pass 'null' or empty string to move to root."),
350
+ position: z
351
+ .string()
352
+ .optional()
353
+ .describe("Optional position string (5-12 chars). Defaults to 'a00000' (end) if omitted."),
354
+ },
355
+ }, async ({ pageId, parentPageId, position }) => {
356
+ // Ensure parentPageId is null if string "null" or empty is passed, or undefined
357
+ // Note: Zod handles type checking, but we double check for empty strings just in case
358
+ const finalParentId = parentPageId === "" || parentPageId === "null" ? null : parentPageId;
359
+ await docmostClient.movePage(pageId, finalParentId || null, position);
360
+ return {
361
+ content: [
362
+ {
363
+ type: "text",
364
+ text: `Successfully moved page ${pageId} to parent ${finalParentId || "root"}`,
365
+ },
366
+ ],
367
+ };
368
+ });
369
+ // Tool: delete_page
370
+ server.registerTool("delete_page", {
371
+ description: "Delete a single page by ID.",
372
+ inputSchema: {
373
+ pageId: z.string(),
374
+ },
375
+ }, async ({ pageId }) => {
376
+ await docmostClient.deletePage(pageId);
377
+ return {
378
+ content: [{ type: "text", text: `Successfully deleted page ${pageId}` }],
379
+ };
380
+ });
381
+ // Tool: delete_pages
382
+ server.registerTool("delete_pages", {
383
+ description: "Delete multiple pages at once. Useful for cleanup.",
384
+ inputSchema: {
385
+ pageIds: z.array(z.string()),
386
+ },
387
+ }, async ({ pageIds }) => {
388
+ const results = await docmostClient.deletePages(pageIds);
389
+ return jsonContent(results);
390
+ });
391
+ // Tool: search
392
+ server.registerTool("search", {
393
+ description: "Search for pages and content.",
394
+ inputSchema: {
395
+ query: z.string().describe("Search query"),
396
+ spaceId: z.string().optional().describe("Optional space ID to filter by"),
397
+ },
398
+ }, async ({ query, spaceId }) => {
399
+ const result = await docmostClient.search(query, spaceId);
400
+ return jsonContent(result);
401
+ });
402
+ async function run() {
403
+ const transport = new StdioServerTransport();
404
+ await server.connect(transport);
405
+ }
406
+ run().catch((error) => {
407
+ console.error("Fatal error running server:", error);
408
+ process.exit(1);
409
+ });
@@ -0,0 +1,43 @@
1
+ import axios from "axios";
2
+ export async function getCollabToken(baseUrl, apiToken) {
3
+ try {
4
+ const response = await axios.post(`${baseUrl}/auth/collab-token`, {}, {
5
+ headers: {
6
+ Authorization: `Bearer ${apiToken}`,
7
+ "Content-Type": "application/json",
8
+ },
9
+ });
10
+ // console.error('Collab Token Response:', response.data);
11
+ // Response is wrapped in { data: { token: ... } }
12
+ return response.data.data?.token || response.data.token;
13
+ }
14
+ catch (error) {
15
+ if (axios.isAxiosError(error)) {
16
+ throw new Error(`Failed to get collab token: ${error.response?.status} ${error.response?.statusText} - ${JSON.stringify(error.response?.data)}`);
17
+ }
18
+ throw error;
19
+ }
20
+ }
21
+ export async function performLogin(baseUrl, email, password) {
22
+ try {
23
+ const response = await axios.post(`${baseUrl}/auth/login`, {
24
+ email,
25
+ password,
26
+ });
27
+ // Extract token from Set-Cookie header
28
+ const cookies = response.headers["set-cookie"];
29
+ if (!cookies) {
30
+ throw new Error("No Set-Cookie header found in login response");
31
+ }
32
+ const authCookie = cookies.find((c) => c.startsWith("authToken="));
33
+ if (!authCookie) {
34
+ throw new Error("No authToken cookie found in login response");
35
+ }
36
+ const token = authCookie.split(";")[0].split("=")[1];
37
+ return token;
38
+ }
39
+ catch (error) {
40
+ console.error("Login failed:", axios.isAxiosError(error) ? error.response?.data : error.message);
41
+ throw error;
42
+ }
43
+ }
@@ -0,0 +1,111 @@
1
+ import { HocuspocusProvider } from "@hocuspocus/provider";
2
+ import { TiptapTransformer } from "@hocuspocus/transformer";
3
+ import * as Y from "yjs";
4
+ import WebSocket from "ws";
5
+ import { marked } from "marked";
6
+ import { generateJSON } from "@tiptap/html";
7
+ import { JSDOM } from "jsdom";
8
+ import { tiptapExtensions } from "./tiptap-extensions.js";
9
+ // Setup DOM environment for Tiptap HTML parsing in Node.js
10
+ const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
11
+ global.window = dom.window;
12
+ global.document = dom.window.document;
13
+ // @ts-ignore
14
+ global.Element = dom.window.Element;
15
+ // @ts-ignore
16
+ global.WebSocket = WebSocket;
17
+ // Navigator is read-only in newer Node versions and already exists
18
+ // global.navigator = dom.window.navigator;
19
+ export async function updatePageContentRealtime(pageId, markdownContent, collabToken, baseUrl) {
20
+ console.error(`Starting realtime update for page ${pageId}`);
21
+ console.error(`Token prefix: ${collabToken ? collabToken.substring(0, 5) : "NONE"}...`);
22
+ // 1. Convert Markdown to HTML
23
+ const html = await marked.parse(markdownContent);
24
+ // 2. Convert HTML to ProseMirror JSON
25
+ const tiptapJson = generateJSON(html, tiptapExtensions);
26
+ // 3. Setup Hocuspocus Provider
27
+ const ydoc = new Y.Doc();
28
+ // Construct WebSocket URL
29
+ // Replace protocol
30
+ let wsUrl = baseUrl.replace(/^http/, "ws");
31
+ try {
32
+ const urlObj = new URL(wsUrl);
33
+ // Remove /api suffix if present, as the websocket is mounted on root /collab
34
+ if (urlObj.pathname.endsWith("/api") || urlObj.pathname.endsWith("/api/")) {
35
+ urlObj.pathname = urlObj.pathname.replace(/\/api\/?$/, "");
36
+ }
37
+ // Set correct path to /collab
38
+ urlObj.pathname = urlObj.pathname.replace(/\/$/, "") + "/collab";
39
+ wsUrl = urlObj.toString();
40
+ }
41
+ catch (e) {
42
+ // Fallback if URL parsing fails
43
+ if (!wsUrl.endsWith("/collab")) {
44
+ wsUrl = wsUrl.replace(/\/$/, "") + "/collab";
45
+ }
46
+ }
47
+ console.error(`Connecting to WebSocket: ${wsUrl}`);
48
+ return new Promise((resolve, reject) => {
49
+ // Safety timeout
50
+ const timer = setTimeout(() => {
51
+ if (provider)
52
+ provider.destroy();
53
+ reject(new Error("Connection timeout to collaboration server"));
54
+ }, 25000);
55
+ const provider = new HocuspocusProvider({
56
+ url: wsUrl,
57
+ name: `page.${pageId}`,
58
+ document: ydoc,
59
+ token: collabToken,
60
+ // @ts-ignore - Required for Node.js environment
61
+ WebSocketPolyfill: WebSocket,
62
+ onConnect: () => console.error("WS Connect"),
63
+ onDisconnect: (data) => console.error("WS Disconnect"),
64
+ onClose: (data) => console.error("WS Close"),
65
+ onSynced: () => {
66
+ console.error("Connected and synced!");
67
+ try {
68
+ // Prepare the new content in a separate doc
69
+ const tempDoc = TiptapTransformer.toYdoc(tiptapJson, "default", tiptapExtensions);
70
+ // Apply update
71
+ ydoc.transact(() => {
72
+ const fragment = ydoc.getXmlFragment("default");
73
+ // 1. Clear existing content
74
+ if (fragment.length > 0) {
75
+ fragment.delete(0, fragment.length);
76
+ }
77
+ // 2. Apply new content from tempDoc
78
+ // Note: applyUpdate adds content. Since we cleared, it should effectively replace.
79
+ // However, applyUpdate merges structures based on IDs. tempDoc has new IDs.
80
+ const update = Y.encodeStateAsUpdate(tempDoc);
81
+ Y.applyUpdate(ydoc, update);
82
+ });
83
+ console.error("Content replaced. Returning success to user immediately (Background persistence)...");
84
+ // Clear safety timeout as we are successful
85
+ clearTimeout(timer);
86
+ // Resolve immediately so the user doesn't have to wait
87
+ resolve();
88
+ // Keep connection open in background for save/sync (Docmost has 10s debounce)
89
+ // The node process will keep running this timeout even after the tool returns
90
+ setTimeout(() => {
91
+ try {
92
+ console.error(`Closing background connection for page ${pageId}`);
93
+ provider.destroy();
94
+ }
95
+ catch (err) { }
96
+ }, 15000);
97
+ }
98
+ catch (e) {
99
+ clearTimeout(timer);
100
+ provider.destroy();
101
+ reject(e);
102
+ }
103
+ },
104
+ onAuthenticationFailed: () => {
105
+ clearTimeout(timer);
106
+ provider.destroy();
107
+ reject(new Error("Authentication failed for collaboration connection"));
108
+ },
109
+ });
110
+ });
111
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Filter functions to extract only relevant information from API responses
3
+ * for better agent consumption
4
+ */
5
+ export function filterWorkspace(data) {
6
+ return {
7
+ id: data.id,
8
+ name: data.name,
9
+ description: data.description,
10
+ defaultSpaceId: data.defaultSpaceId,
11
+ createdAt: data.createdAt,
12
+ updatedAt: data.updatedAt,
13
+ deletedAt: data.deletedAt,
14
+ };
15
+ }
16
+ export function filterSpace(space) {
17
+ return {
18
+ id: space.id,
19
+ name: space.name,
20
+ description: space.description,
21
+ slug: space.slug,
22
+ visibility: space.visibility,
23
+ createdAt: space.createdAt,
24
+ updatedAt: space.updatedAt,
25
+ deletedAt: space.deletedAt,
26
+ };
27
+ }
28
+ export function filterGroup(group) {
29
+ return {
30
+ id: group.id,
31
+ name: group.name,
32
+ description: group.description,
33
+ workspaceId: group.workspaceId,
34
+ createdAt: group.createdAt,
35
+ updatedAt: group.updatedAt,
36
+ deletedAt: group.deletedAt,
37
+ };
38
+ }
39
+ export function filterPage(page, content, subpages) {
40
+ return {
41
+ id: page.id,
42
+ title: page.title,
43
+ parentPageId: page.parentPageId,
44
+ spaceId: page.spaceId,
45
+ isLocked: page.isLocked,
46
+ createdAt: page.createdAt,
47
+ updatedAt: page.updatedAt,
48
+ deletedAt: page.deletedAt,
49
+ // Include converted markdown content if valid string (even empty)
50
+ ...(typeof content === "string" && { content }),
51
+ // Include subpages if provided
52
+ ...(subpages &&
53
+ subpages.length > 0 && {
54
+ subpages: subpages.map((p) => ({ id: p.id, title: p.title })),
55
+ }),
56
+ };
57
+ }
58
+ export function filterSearchResult(result) {
59
+ return {
60
+ id: result.id,
61
+ title: result.title,
62
+ parentPageId: result.parentPageId,
63
+ createdAt: result.createdAt,
64
+ updatedAt: result.updatedAt,
65
+ rank: result.rank,
66
+ highlight: result.highlight,
67
+ spaceId: result.space?.id,
68
+ spaceName: result.space?.name,
69
+ };
70
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Convert ProseMirror/TipTap JSON content to Markdown
3
+ * Supports all Docmost-specific node types and extensions
4
+ */
5
+ export function convertProseMirrorToMarkdown(content) {
6
+ if (!content || !content.content)
7
+ return "";
8
+ const processNode = (node) => {
9
+ const type = node.type;
10
+ const nodeContent = node.content || [];
11
+ switch (type) {
12
+ case "doc":
13
+ return nodeContent.map(processNode).join("\n\n");
14
+ case "paragraph":
15
+ const text = nodeContent.map(processNode).join("");
16
+ const align = node.attrs?.textAlign;
17
+ if (align && align !== "left") {
18
+ return `<div align="${align}">${text}</div>`;
19
+ }
20
+ return text || "";
21
+ case "heading":
22
+ const level = node.attrs?.level || 1;
23
+ const headingText = nodeContent.map(processNode).join("");
24
+ return "#".repeat(level) + " " + headingText;
25
+ case "text":
26
+ let textContent = node.text || "";
27
+ // Apply marks (bold, italic, code, etc.)
28
+ if (node.marks) {
29
+ for (const mark of node.marks) {
30
+ switch (mark.type) {
31
+ case "bold":
32
+ textContent = `**${textContent}**`;
33
+ break;
34
+ case "italic":
35
+ textContent = `*${textContent}*`;
36
+ break;
37
+ case "code":
38
+ textContent = `\`${textContent}\``;
39
+ break;
40
+ case "link":
41
+ textContent = `[${textContent}](${mark.attrs?.href || ""})`;
42
+ break;
43
+ case "strike":
44
+ textContent = `~~${textContent}~~`;
45
+ break;
46
+ case "underline":
47
+ textContent = `<u>${textContent}</u>`;
48
+ break;
49
+ case "subscript":
50
+ textContent = `<sub>${textContent}</sub>`;
51
+ break;
52
+ case "superscript":
53
+ textContent = `<sup>${textContent}</sup>`;
54
+ break;
55
+ case "highlight":
56
+ const color = mark.attrs?.color || "yellow";
57
+ textContent = `<mark style="background-color: ${color}">${textContent}</mark>`;
58
+ break;
59
+ case "textStyle":
60
+ if (mark.attrs?.color) {
61
+ textContent = `<span style="color: ${mark.attrs.color}">${textContent}</span>`;
62
+ }
63
+ break;
64
+ }
65
+ }
66
+ }
67
+ return textContent;
68
+ case "codeBlock":
69
+ const language = node.attrs?.language || "";
70
+ const code = nodeContent.map(processNode).join("");
71
+ return "```" + language + "\n" + code + "\n```";
72
+ case "bulletList":
73
+ return nodeContent
74
+ .map((item) => processListItem(item, "-"))
75
+ .join("\n");
76
+ case "orderedList":
77
+ return nodeContent
78
+ .map((item, index) => processListItem(item, `${index + 1}.`))
79
+ .join("\n");
80
+ case "taskList":
81
+ return nodeContent.map((item) => processTaskItem(item)).join("\n");
82
+ case "taskItem":
83
+ const checked = node.attrs?.checked || false;
84
+ const checkbox = checked ? "[x]" : "[ ]";
85
+ return `- ${checkbox} ${nodeContent.map(processNode).join("\n")}`;
86
+ case "listItem":
87
+ return nodeContent.map(processNode).join("\n");
88
+ case "blockquote":
89
+ return nodeContent.map((n) => "> " + processNode(n)).join("\n");
90
+ case "horizontalRule":
91
+ return "---";
92
+ case "hardBreak":
93
+ return "\n";
94
+ case "image":
95
+ const imgAlt = node.attrs?.alt || "";
96
+ const imgSrc = node.attrs?.src || "";
97
+ const imgCaption = node.attrs?.caption || "";
98
+ return `![${imgAlt}](${imgSrc})${imgCaption ? `\n*${imgCaption}*` : ""}`;
99
+ case "video":
100
+ const videoSrc = node.attrs?.src || "";
101
+ return `🎥 [Video](${videoSrc})`;
102
+ case "youtube":
103
+ const youtubeUrl = node.attrs?.src || "";
104
+ return `📺 [YouTube Video](${youtubeUrl})`;
105
+ case "table":
106
+ return nodeContent.map(processNode).join("\n");
107
+ case "tableRow":
108
+ return "| " + nodeContent.map(processNode).join(" | ") + " |";
109
+ case "tableCell":
110
+ case "tableHeader":
111
+ return nodeContent.map(processNode).join("");
112
+ case "callout":
113
+ const calloutType = node.attrs?.type || "info";
114
+ const calloutContent = nodeContent.map(processNode).join("\n");
115
+ return `:::${calloutType.toLowerCase()}\n${calloutContent}\n:::`;
116
+ case "details":
117
+ return nodeContent.map(processNode).join("\n");
118
+ case "detailsSummary":
119
+ const summaryText = nodeContent.map(processNode).join("");
120
+ return `<details>\n<summary>${summaryText}</summary>\n`;
121
+ case "detailsContent":
122
+ const detailsText = nodeContent.map(processNode).join("\n");
123
+ return `${detailsText}\n</details>`;
124
+ case "mathInline":
125
+ const inlineMath = node.attrs?.text || "";
126
+ return `$${inlineMath}$`;
127
+ case "mathBlock":
128
+ const blockMath = node.attrs?.text || "";
129
+ return `$$\n${blockMath}\n$$`;
130
+ case "mention":
131
+ const mentionLabel = node.attrs?.label || node.attrs?.id || "";
132
+ return `@${mentionLabel}`;
133
+ case "attachment":
134
+ const attachmentName = node.attrs?.fileName || "attachment";
135
+ const attachmentUrl = node.attrs?.src || "";
136
+ return `📎 [${attachmentName}](${attachmentUrl})`;
137
+ case "drawio":
138
+ return `📊 [Draw.io Diagram]`;
139
+ case "excalidraw":
140
+ return `✏️ [Excalidraw Drawing]`;
141
+ case "embed":
142
+ const embedUrl = node.attrs?.src || "";
143
+ return `🔗 [Embedded Content](${embedUrl})`;
144
+ case "subpages":
145
+ return "{{SUBPAGES}}";
146
+ default:
147
+ // Fallback: process children
148
+ return nodeContent.map(processNode).join("");
149
+ }
150
+ };
151
+ const processListItem = (item, prefix) => {
152
+ const itemContent = item.content || [];
153
+ const lines = itemContent.map(processNode);
154
+ return lines
155
+ .map((line, i) => i === 0 ? `${prefix} ${line}` : ` ${line}`)
156
+ .join("\n");
157
+ };
158
+ const processTaskItem = (item) => {
159
+ const checked = item.attrs?.checked || false;
160
+ const checkbox = checked ? "[x]" : "[ ]";
161
+ const itemContent = item.content || [];
162
+ const text = itemContent.map(processNode).join("");
163
+ return `- ${checkbox} ${text}`;
164
+ };
165
+ return processNode(content).trim();
166
+ }
@@ -0,0 +1,18 @@
1
+ import StarterKit from "@tiptap/starter-kit";
2
+ import Image from "@tiptap/extension-image";
3
+ import Link from "@tiptap/extension-link";
4
+ // Define extensions compatible with standard Markdown features
5
+ // We use the default Tiptap extensions to handle basic content
6
+ export const tiptapExtensions = [
7
+ StarterKit.configure({
8
+ // Explicitly enable features that might be disabled in some contexts
9
+ codeBlock: {},
10
+ heading: {},
11
+ }),
12
+ Image.configure({
13
+ inline: true,
14
+ }),
15
+ Link.configure({
16
+ openOnClick: false,
17
+ }),
18
+ ];
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "docmost-mcp",
3
+ "version": "1.0.1",
4
+ "description": "A Model Context Protocol (MCP) server for Docmost, allowing AI agents to manage documentation spaces and pages.",
5
+ "main": "build/index.js",
6
+ "bin": {
7
+ "docmost-mcp": "build/index.js"
8
+ },
9
+ "files": [
10
+ "build"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "prepublishOnly": "tsc",
15
+ "start": "node build/index.js",
16
+ "watch": "tsc --watch",
17
+ "inspector": "npx @modelcontextprotocol/inspector --config mcp_config.json"
18
+ },
19
+ "keywords": [
20
+ "mcp",
21
+ "docmost",
22
+ "documentation",
23
+ "ai",
24
+ "agent"
25
+ ],
26
+ "author": "Moritz Krause",
27
+ "license": "MIT",
28
+ "type": "module",
29
+ "dependencies": {
30
+ "@hocuspocus/provider": "^3.4.4",
31
+ "@hocuspocus/transformer": "^3.4.4",
32
+ "@modelcontextprotocol/sdk": "^1.25.3",
33
+ "@tiptap/core": "^3.18.0",
34
+ "@tiptap/extension-image": "^3.18.0",
35
+ "@tiptap/extension-link": "^3.18.0",
36
+ "@tiptap/html": "^3.18.0",
37
+ "@tiptap/starter-kit": "^3.18.0",
38
+ "@types/jsdom": "^27.0.0",
39
+ "axios": "^1.6.0",
40
+ "form-data": "^4.0.0",
41
+ "jsdom": "^27.4.0",
42
+ "marked": "^17.0.1",
43
+ "ws": "^8.19.0",
44
+ "yjs": "^13.6.29",
45
+ "zod": "^3.22.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/form-data": "^2.5.0",
49
+ "@types/node": "^20.0.0",
50
+ "typescript": "^5.0.0"
51
+ }
52
+ }