feat: Introduce markdown converter and auth utilities, alongside various client and server-side updates and dependency changes.

This commit is contained in:
Moritz Krause
2026-02-01 04:24:41 +01:00
parent 0900260765
commit 493ce01254
9 changed files with 1868 additions and 125 deletions

View File

@@ -7,12 +7,7 @@ A Model Context Protocol (MCP) server for [Docmost](https://docmost.com/), enabl
### Core Management ### Core Management
- **`create_page`**: Smart creation tool. Creates content (via import) AND handles hierarchy (nesting under a parent) in one go. - **`create_page`**: Smart creation tool. Creates content (via import) AND handles hierarchy (nesting under a parent) in one go.
- **`update_page`**: ⚠️ **IMPORTANT**: This tool **recreates the page** with a **new ID** and **deletes the old page**. While it preserves child pages and hierarchy, **all references to the old page ID will break**, including: - **`update_page`**: Update a page's content and/or title. Updates are performed via real-time collaboration (WebSocket).
- Comments and discussions
- Page history/changelog
This is a workaround limitation due to Docmost's API not providing a native update endpoint. Use with caution!
- **`delete_page` / `delete_pages`**: Delete single or multiple pages at once. - **`delete_page` / `delete_pages`**: Delete single or multiple pages at once.
- **`move_page`**: Organize pages hierarchically by moving them to a new parent or root. - **`move_page`**: Organize pages hierarchically by moving them to a new parent or root.
@@ -55,13 +50,13 @@ Add the following to your MCP configuration (e.g. `claude_desktop_config.json`):
```json ```json
{ {
"mcpServers": { "mcpServers": {
"docmost": { "docmost-local": {
"command": "node", "command": "node",
"args": ["/path/to/docmost-mcp/build/index.js"], "args": ["./build/index.js"],
"env": { "env": {
"DOCMOST_API_URL": "https://your-docmost-instance.com/api", "DOCMOST_API_URL": "http://localhost:3000/api",
"DOCMOST_EMAIL": "your-email@example.com", "DOCMOST_EMAIL": "test@docmost.com",
"DOCMOST_PASSWORD": "your-password" "DOCMOST_PASSWORD": "test"
} }
} }
} }

1524
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,9 +23,21 @@
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@hocuspocus/provider": "^3.4.4",
"@hocuspocus/transformer": "^3.4.4",
"@modelcontextprotocol/sdk": "^1.25.3", "@modelcontextprotocol/sdk": "^1.25.3",
"@tiptap/core": "^3.18.0",
"@tiptap/extension-image": "^3.18.0",
"@tiptap/extension-link": "^3.18.0",
"@tiptap/html": "^3.18.0",
"@tiptap/starter-kit": "^3.18.0",
"@types/jsdom": "^27.0.0",
"axios": "^1.6.0", "axios": "^1.6.0",
"form-data": "^4.0.0", "form-data": "^4.0.0",
"jsdom": "^27.4.0",
"marked": "^17.0.1",
"ws": "^8.19.0",
"yjs": "^13.6.29",
"zod": "^3.22.0" "zod": "^3.22.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -9,11 +9,13 @@ import {
filterGroup, filterGroup,
filterPage, filterPage,
filterSearchResult, filterSearchResult,
} from "./filters.js"; } from "./lib/filters.js";
import { convertProseMirrorToMarkdown } from "./markdown-converter.js"; import { convertProseMirrorToMarkdown } from "./lib/markdown-converter.js";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { dirname, join } from "path"; import { dirname, join } from "path";
import { updatePageContentRealtime } from "./lib/collaboration.js";
import { getCollabToken, performLogin } from "./lib/auth-utils.js";
// Read version from package.json // Read version from package.json
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -49,33 +51,16 @@ class DocmostClient {
} }
async login() { async login() {
try { if (!EMAIL || !PASSWORD) {
const response = await this.client.post("/auth/login", { throw new Error("Missing Credentials (DOCMOST_EMAIL, DOCMOST_PASSWORD)");
email: EMAIL,
password: PASSWORD,
});
// Extract token from Set-Cookie header
const cookies = response.headers["set-cookie"];
if (!cookies) {
throw new Error("No Set-Cookie header found in login response");
}
const authCookie = cookies.find((c: string) =>
c.startsWith("authToken="),
);
if (!authCookie) {
throw new Error("No authToken cookie found in login response");
}
const token = authCookie.split(";")[0].split("=")[1];
this.token = token;
this.client.defaults.headers.common["Authorization"] = `Bearer ${token}`;
// console.error("Successfully logged in to Docmost API");
} catch (error: any) {
console.error("Login failed:", error.response?.data || error.message);
throw error;
} }
// 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() { async ensureAuthenticated() {
@@ -151,16 +136,48 @@ class DocmostClient {
return pages.map((page) => filterPage(page)); 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) { async getPage(pageId: string) {
await this.ensureAuthenticated(); await this.ensureAuthenticated();
const response = await this.client.post("/pages/info", { pageId }); 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 { return {
data: filterPage( data: filterPage(resultData, content, subpages),
response.data.data,
response.data.data.content
? convertProseMirrorToMarkdown(response.data.data.content)
: undefined,
),
success: response.data.success, success: response.data.success,
}; };
} }
@@ -190,9 +207,26 @@ class DocmostClient {
} }
} }
// 1. Create content via Import // 1. Create content via Import (using multipart/form-data)
const importRes = await this.importPage(title, content, spaceId); const form = new FormData();
const newPageId = importRes.data.id; 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 // 2. Move to parent if needed
if (parentPageId) { if (parentPageId) {
@@ -205,82 +239,43 @@ class DocmostClient {
/** /**
* Update a page's content and optionally its title. * Update a page's content and optionally its title.
* * Leverages WebSocket collaboration to update content without changing Page ID.
* Note: As long as Docmost doesn't provide a /pages/update endpoint, we must use
* a "swap-and-replace" workaround to update page content. This method:
* 1. Fetches the old page details (space, parent, title)
* 2. Identifies all child pages
* 3. Creates a NEW page with the updated content via /pages/import
* 4. Moves the new page to the old page's position (same parent)
* 5. Re-parents all children to the new page
* 6. Deletes the old page
*
* ⚠️ IMPORTANT LIMITATION: The page will get a NEW ID! Any external references
* to the old page ID will break. The new ID is returned in the response.
*/ */
async updatePage(pageId: string, content: string, title?: string) { async updatePage(pageId: string, content: string, title?: string) {
await this.ensureAuthenticated(); await this.ensureAuthenticated();
// 1. Get old page details to know space and parent // 1. Update Title via REST API if provided
const oldPageRes = await this.getPage(pageId); if (title) {
const oldPage = oldPageRes.data; await this.client.post("/pages/update", { pageId, title });
const spaceId = oldPage.spaceId;
const parentPageId = oldPage.parentPageId;
const effectiveTitle = title || oldPage.title || "Untitled";
// 2. Identify Children
const allPages = await this.listPages(spaceId);
const children = allPages.filter((p: any) => p.parentPageId === pageId);
// 3. Create NEW page
const importRes = await this.importPage(effectiveTitle, content, spaceId);
const newPageId = importRes.data.id;
// 4. Move NEW page to OLD page's position (same parent)
if (parentPageId) {
await this.movePage(newPageId, parentPageId);
} }
// 5. Reparent Children (Rescue them!) // 2. Update Content via WebSocket
for (const child of children) { let collabToken = "";
await this.movePage(child.id, newPageId); 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})`,
);
} }
// 6. Delete OLD page
await this.deletePage(pageId);
return { return {
success: true, success: true,
newMessage: modified: true,
"Page updated safely (via swap-and-replace). Children preserved.", message: "Page updated successfully.",
newPageId: newPageId, pageId: pageId,
preservedChildrenCount: children.length,
}; };
} }
async importPage(title: string, content: string, spaceId: string) {
await this.ensureAuthenticated();
const form = new FormData();
form.append("spaceId", spaceId);
// We create a virtual file
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}`,
};
return axios
.post(`${API_URL}/pages/import`, form, { headers })
.then((res) => res.data);
}
async search(query: string, spaceId?: string) { async search(query: string, spaceId?: string) {
await this.ensureAuthenticated(); await this.ensureAuthenticated();
const response = await this.client.post("/search", { const response = await this.client.post("/search", {
@@ -447,14 +442,11 @@ server.registerTool(
"update_page", "update_page",
{ {
description: description:
"Update a page's content safely (preserves child pages by moving them). Note: Returns a NEW pageId.", "Update a page's content and/or title via realtime collaboration (preserves Page ID and history).",
inputSchema: { inputSchema: {
pageId: z.string().describe("ID of the page to update"), pageId: z.string().describe("ID of the page to update"),
content: z.string().describe("New Markdown content"), content: z.string().describe("New Markdown content"),
title: z title: z.string().optional().describe("Optional new title"),
.string()
.optional()
.describe("Optional new title (defaults to old title)"),
}, },
}, },
async ({ pageId, content, title }) => { async ({ pageId, content, title }) => {

62
src/lib/auth-utils.ts Normal file
View File

@@ -0,0 +1,62 @@
import axios from "axios";
export async function getCollabToken(
baseUrl: string,
apiToken: string,
): Promise<string> {
try {
const response = await axios.post(
`${baseUrl}/auth/collab-token`,
{},
{
headers: {
Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json",
},
},
);
// console.error('Collab Token Response:', response.data);
// Response is wrapped in { data: { token: ... } }
return response.data.data?.token || response.data.token;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(
`Failed to get collab token: ${error.response?.status} ${error.response?.statusText} - ${JSON.stringify(error.response?.data)}`,
);
}
throw error;
}
}
export async function performLogin(
baseUrl: string,
email: string,
password: string,
): Promise<string> {
try {
const response = await axios.post(`${baseUrl}/auth/login`, {
email,
password,
});
// Extract token from Set-Cookie header
const cookies = response.headers["set-cookie"];
if (!cookies) {
throw new Error("No Set-Cookie header found in login response");
}
const authCookie = cookies.find((c: string) => c.startsWith("authToken="));
if (!authCookie) {
throw new Error("No authToken cookie found in login response");
}
const token = authCookie.split(";")[0].split("=")[1];
return token;
} catch (error: any) {
console.error(
"Login failed:",
axios.isAxiosError(error) ? error.response?.data : error.message,
);
throw error;
}
}

138
src/lib/collaboration.ts Normal file
View File

@@ -0,0 +1,138 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { TiptapTransformer } from "@hocuspocus/transformer";
import * as Y from "yjs";
import WebSocket from "ws";
import { marked } from "marked";
import { generateJSON } from "@tiptap/html";
import { JSDOM } from "jsdom";
import { tiptapExtensions } from "./tiptap-extensions.js";
// Setup DOM environment for Tiptap HTML parsing in Node.js
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;
// @ts-ignore
global.WebSocket = WebSocket;
// Navigator is read-only in newer Node versions and already exists
// global.navigator = dom.window.navigator;
export async function updatePageContentRealtime(
pageId: string,
markdownContent: string,
collabToken: string,
baseUrl: string,
): Promise<void> {
console.error(`Starting realtime update for page ${pageId}`);
console.error(
`Token prefix: ${collabToken ? collabToken.substring(0, 5) : "NONE"}...`,
);
// 1. Convert Markdown to HTML
const html = await marked.parse(markdownContent);
// 2. Convert HTML to ProseMirror JSON
const tiptapJson = generateJSON(html, tiptapExtensions);
// 3. Setup Hocuspocus Provider
const ydoc = new Y.Doc();
// Construct WebSocket URL
// Replace protocol
let wsUrl = baseUrl.replace(/^http/, "ws");
try {
const urlObj = new URL(wsUrl);
// Remove /api suffix if present, as the websocket is mounted on root /collab
if (urlObj.pathname.endsWith("/api") || urlObj.pathname.endsWith("/api/")) {
urlObj.pathname = urlObj.pathname.replace(/\/api\/?$/, "");
}
// Set correct path to /collab
urlObj.pathname = urlObj.pathname.replace(/\/$/, "") + "/collab";
wsUrl = urlObj.toString();
} catch (e) {
// Fallback if URL parsing fails
if (!wsUrl.endsWith("/collab")) {
wsUrl = wsUrl.replace(/\/$/, "") + "/collab";
}
}
console.error(`Connecting to WebSocket: ${wsUrl}`);
return new Promise<void>((resolve, reject) => {
// Safety timeout
const timer = setTimeout(() => {
if (provider) provider.destroy();
reject(new Error("Connection timeout to collaboration server"));
}, 25000);
const provider = new HocuspocusProvider({
url: wsUrl,
name: `page.${pageId}`,
document: ydoc,
token: collabToken,
// @ts-ignore - Required for Node.js environment
WebSocketPolyfill: WebSocket as any,
onConnect: () => console.error("WS Connect"),
onDisconnect: (data) => console.error("WS Disconnect"),
onClose: (data) => console.error("WS Close"),
onSynced: () => {
console.error("Connected and synced!");
try {
// Prepare the new content in a separate doc
const tempDoc = TiptapTransformer.toYdoc(
tiptapJson,
"default",
tiptapExtensions,
);
// Apply update
ydoc.transact(() => {
const fragment = ydoc.getXmlFragment("default");
// 1. Clear existing content
if (fragment.length > 0) {
fragment.delete(0, fragment.length);
}
// 2. Apply new content from tempDoc
// Note: applyUpdate adds content. Since we cleared, it should effectively replace.
// However, applyUpdate merges structures based on IDs. tempDoc has new IDs.
const update = Y.encodeStateAsUpdate(tempDoc);
Y.applyUpdate(ydoc, update);
});
console.error(
"Content replaced. Returning success to user immediately (Background persistence)...",
);
// Clear safety timeout as we are successful
clearTimeout(timer);
// Resolve immediately so the user doesn't have to wait
resolve();
// Keep connection open in background for save/sync (Docmost has 10s debounce)
// The node process will keep running this timeout even after the tool returns
setTimeout(() => {
try {
console.error(`Closing background connection for page ${pageId}`);
provider.destroy();
} catch (err) {}
}, 15000);
} catch (e) {
clearTimeout(timer);
provider.destroy();
reject(e);
}
},
onAuthenticationFailed: () => {
clearTimeout(timer);
provider.destroy();
reject(new Error("Authentication failed for collaboration connection"));
},
});
});
}

View File

@@ -40,7 +40,7 @@ export function filterGroup(group: any) {
}; };
} }
export function filterPage(page: any, content?: string) { export function filterPage(page: any, content?: string, subpages?: any[]) {
return { return {
id: page.id, id: page.id,
title: page.title, title: page.title,
@@ -50,8 +50,13 @@ export function filterPage(page: any, content?: string) {
createdAt: page.createdAt, createdAt: page.createdAt,
updatedAt: page.updatedAt, updatedAt: page.updatedAt,
deletedAt: page.deletedAt, deletedAt: page.deletedAt,
// Include converted markdown content if provided // Include converted markdown content if valid string (even empty)
...(content && { content }), ...(typeof content === "string" && { content }),
// Include subpages if provided
...(subpages &&
subpages.length > 0 && {
subpages: subpages.map((p) => ({ id: p.id, title: p.title })),
}),
}; };
} }

View File

@@ -134,7 +134,7 @@ export function convertProseMirrorToMarkdown(content: any): string {
case "callout": case "callout":
const calloutType = node.attrs?.type || "info"; const calloutType = node.attrs?.type || "info";
const calloutContent = nodeContent.map(processNode).join("\n"); const calloutContent = nodeContent.map(processNode).join("\n");
return `> **${calloutType.toUpperCase()}**\n> ${calloutContent.replace(/\n/g, "\n> ")}`; return `:::${calloutType.toLowerCase()}\n${calloutContent}\n:::`;
case "details": case "details":
return nodeContent.map(processNode).join("\n"); return nodeContent.map(processNode).join("\n");
@@ -148,11 +148,11 @@ export function convertProseMirrorToMarkdown(content: any): string {
return `${detailsText}\n</details>`; return `${detailsText}\n</details>`;
case "mathInline": case "mathInline":
const inlineMath = node.attrs?.latex || ""; const inlineMath = node.attrs?.text || "";
return `$${inlineMath}$`; return `$${inlineMath}$`;
case "mathBlock": case "mathBlock":
const blockMath = node.attrs?.latex || ""; const blockMath = node.attrs?.text || "";
return `$$\n${blockMath}\n$$`; return `$$\n${blockMath}\n$$`;
case "mention": case "mention":
@@ -175,7 +175,7 @@ export function convertProseMirrorToMarkdown(content: any): string {
return `🔗 [Embedded Content](${embedUrl})`; return `🔗 [Embedded Content](${embedUrl})`;
case "subpages": case "subpages":
return `📑 [Subpages List]`; return "{{SUBPAGES}}";
default: default:
// Fallback: process children // Fallback: process children

View File

@@ -0,0 +1,19 @@
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
// Define extensions compatible with standard Markdown features
// We use the default Tiptap extensions to handle basic content
export const tiptapExtensions = [
StarterKit.configure({
// Explicitly enable features that might be disabled in some contexts
codeBlock: {},
heading: {},
}),
Image.configure({
inline: true,
}),
Link.configure({
openOnClick: false,
}),
];