feat: implement initial Docmost Model Context Protocol (MCP) server for documentation management.
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
mcp_config.json
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Moritz Krause
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
83
README.md
Normal file
83
README.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Docmost MCP Server
|
||||||
|
|
||||||
|
A Model Context Protocol (MCP) server for [Docmost](https://docmost.com/), enabling AI agents to search, create, modify, and organize documentation pages and spaces.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Core Management
|
||||||
|
|
||||||
|
- **`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:
|
||||||
|
- 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.
|
||||||
|
- **`move_page`**: Organize pages hierarchically by moving them to a new parent or root.
|
||||||
|
|
||||||
|
### Exploration & Retrieval
|
||||||
|
|
||||||
|
- **`search`**: Full-text search across spaces with optional space filtering (`query`, `spaceId`).
|
||||||
|
- **`get_workspace`**: Get information about the current Docmost workspace.
|
||||||
|
- **`list_spaces`**: View all spaces within the current workspace.
|
||||||
|
- **`list_groups`**: View all groups within the current workspace.
|
||||||
|
- **`list_pages`**: List pages within a space (ordered by `updatedAt` descending).
|
||||||
|
- **`get_page`**: Retrieve full content and metadata of a specific page.
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **Smart Import API**: Uses Docmost's import API to ensure clean Markdown-to-ProseMirror conversion when creating pages.
|
||||||
|
- **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.
|
||||||
|
- **Pagination Support**: Automatically handles pagination for large datasets (spaces, pages, groups).
|
||||||
|
- **Filtered Responses**: API responses are filtered to include only relevant information, optimizing data transfer for agents.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
This server requires the following environment variables to be set:
|
||||||
|
|
||||||
|
- `DOCMOST_API_URL`: The full URL to your Docmost API (e.g., `https://docs.example.com/api`).
|
||||||
|
- `DOCMOST_EMAIL`: The email address for authentication.
|
||||||
|
- `DOCMOST_PASSWORD`: The password for authentication.
|
||||||
|
|
||||||
|
## usage with Claude Desktop / generic MCP Client
|
||||||
|
|
||||||
|
Add the following to your MCP configuration (e.g. `claude_desktop_config.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"docmost": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["/path/to/docmost-mcp/build/index.js"],
|
||||||
|
"env": {
|
||||||
|
"DOCMOST_API_URL": "https://your-docmost-instance.com/api",
|
||||||
|
"DOCMOST_EMAIL": "your-email@example.com",
|
||||||
|
"DOCMOST_PASSWORD": "your-password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Watch mode
|
||||||
|
npm run watch
|
||||||
|
|
||||||
|
# Build
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
13
mcp_config_example.json
Normal file
13
mcp_config_example.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"docmost-local": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["./build/index.js"],
|
||||||
|
"env": {
|
||||||
|
"DOCMOST_API_URL": "http://localhost:3000/api",
|
||||||
|
"DOCMOST_EMAIL": "test@docmost.com",
|
||||||
|
"DOCMOST_PASSWORD": "test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1313
package-lock.json
generated
Normal file
1313
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "docmost-mcp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A Model Context Protocol (MCP) server for Docmost, allowing AI agents to manage documentation spaces and pages.",
|
||||||
|
"main": "build/index.js",
|
||||||
|
"bin": {
|
||||||
|
"docmost-mcp": "build/index.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node build/index.js",
|
||||||
|
"watch": "tsc --watch",
|
||||||
|
"inspector": "npx @modelcontextprotocol/inspector --config mcp_config.json"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"mcp",
|
||||||
|
"docmost",
|
||||||
|
"documentation",
|
||||||
|
"ai",
|
||||||
|
"agent"
|
||||||
|
],
|
||||||
|
"author": "Moritz Krause",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.25.3",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"form-data": "^4.0.0",
|
||||||
|
"zod": "^3.22.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/form-data": "^2.5.0",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/filters.ts
Normal file
70
src/filters.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Filter functions to extract only relevant information from API responses
|
||||||
|
* for better agent consumption
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function filterWorkspace(data: any) {
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
defaultSpaceId: data.defaultSpaceId,
|
||||||
|
createdAt: data.createdAt,
|
||||||
|
updatedAt: data.updatedAt,
|
||||||
|
deletedAt: data.deletedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterSpace(space: any) {
|
||||||
|
return {
|
||||||
|
id: space.id,
|
||||||
|
name: space.name,
|
||||||
|
description: space.description,
|
||||||
|
slug: space.slug,
|
||||||
|
visibility: space.visibility,
|
||||||
|
createdAt: space.createdAt,
|
||||||
|
updatedAt: space.updatedAt,
|
||||||
|
deletedAt: space.deletedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterGroup(group: any) {
|
||||||
|
return {
|
||||||
|
id: group.id,
|
||||||
|
name: group.name,
|
||||||
|
description: group.description,
|
||||||
|
workspaceId: group.workspaceId,
|
||||||
|
createdAt: group.createdAt,
|
||||||
|
updatedAt: group.updatedAt,
|
||||||
|
deletedAt: group.deletedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterPage(page: any, content?: string) {
|
||||||
|
return {
|
||||||
|
id: page.id,
|
||||||
|
title: page.title,
|
||||||
|
parentPageId: page.parentPageId,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
isLocked: page.isLocked,
|
||||||
|
createdAt: page.createdAt,
|
||||||
|
updatedAt: page.updatedAt,
|
||||||
|
deletedAt: page.deletedAt,
|
||||||
|
// Include converted markdown content if provided
|
||||||
|
...(content && { content }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterSearchResult(result: any) {
|
||||||
|
return {
|
||||||
|
id: result.id,
|
||||||
|
title: result.title,
|
||||||
|
parentPageId: result.parentPageId,
|
||||||
|
createdAt: result.createdAt,
|
||||||
|
updatedAt: result.updatedAt,
|
||||||
|
rank: result.rank,
|
||||||
|
highlight: result.highlight,
|
||||||
|
spaceId: result.space?.id,
|
||||||
|
spaceName: result.space?.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
563
src/index.ts
Normal file
563
src/index.ts
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
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,
|
||||||
|
filterSearchResult,
|
||||||
|
} from "./filters.js";
|
||||||
|
import { convertProseMirrorToMarkdown } from "./markdown-converter.js";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { dirname, join } from "path";
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
try {
|
||||||
|
const response = await this.client.post("/auth/login", {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getPage(pageId: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const response = await this.client.post("/pages/info", { pageId });
|
||||||
|
return {
|
||||||
|
data: filterPage(
|
||||||
|
response.data.data,
|
||||||
|
response.data.data.content
|
||||||
|
? convertProseMirrorToMarkdown(response.data.data.content)
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
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
|
||||||
|
const importRes = await this.importPage(title, content, spaceId);
|
||||||
|
const newPageId = importRes.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.
|
||||||
|
*
|
||||||
|
* 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) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
|
||||||
|
// 1. Get old page details to know space and parent
|
||||||
|
const oldPageRes = await this.getPage(pageId);
|
||||||
|
const oldPage = oldPageRes.data;
|
||||||
|
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!)
|
||||||
|
for (const child of children) {
|
||||||
|
await this.movePage(child.id, newPageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Delete OLD page
|
||||||
|
await this.deletePage(pageId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
newMessage:
|
||||||
|
"Page updated safely (via swap-and-replace). Children preserved.",
|
||||||
|
newPageId: newPageId,
|
||||||
|
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) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const response = await this.client.post("/search", {
|
||||||
|
query,
|
||||||
|
spaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter search results (data is directly an array)
|
||||||
|
const items = response.data?.data || [];
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 safely (preserves child pages by moving them). Note: Returns a NEW pageId.",
|
||||||
|
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 (defaults to old 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
console.error("Fatal error running server:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
205
src/markdown-converter.ts
Normal file
205
src/markdown-converter.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* Convert ProseMirror/TipTap JSON content to Markdown
|
||||||
|
* Supports all Docmost-specific node types and extensions
|
||||||
|
*/
|
||||||
|
export function convertProseMirrorToMarkdown(content: any): string {
|
||||||
|
if (!content || !content.content) return "";
|
||||||
|
|
||||||
|
const processNode = (node: any): string => {
|
||||||
|
const type = node.type;
|
||||||
|
const nodeContent = node.content || [];
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "doc":
|
||||||
|
return nodeContent.map(processNode).join("\n\n");
|
||||||
|
|
||||||
|
case "paragraph":
|
||||||
|
const text = nodeContent.map(processNode).join("");
|
||||||
|
const align = node.attrs?.textAlign;
|
||||||
|
if (align && align !== "left") {
|
||||||
|
return `<div align="${align}">${text}</div>`;
|
||||||
|
}
|
||||||
|
return text || "";
|
||||||
|
|
||||||
|
case "heading":
|
||||||
|
const level = node.attrs?.level || 1;
|
||||||
|
const headingText = nodeContent.map(processNode).join("");
|
||||||
|
return "#".repeat(level) + " " + headingText;
|
||||||
|
|
||||||
|
case "text":
|
||||||
|
let textContent = node.text || "";
|
||||||
|
// Apply marks (bold, italic, code, etc.)
|
||||||
|
if (node.marks) {
|
||||||
|
for (const mark of node.marks) {
|
||||||
|
switch (mark.type) {
|
||||||
|
case "bold":
|
||||||
|
textContent = `**${textContent}**`;
|
||||||
|
break;
|
||||||
|
case "italic":
|
||||||
|
textContent = `*${textContent}*`;
|
||||||
|
break;
|
||||||
|
case "code":
|
||||||
|
textContent = `\`${textContent}\``;
|
||||||
|
break;
|
||||||
|
case "link":
|
||||||
|
textContent = `[${textContent}](${mark.attrs?.href || ""})`;
|
||||||
|
break;
|
||||||
|
case "strike":
|
||||||
|
textContent = `~~${textContent}~~`;
|
||||||
|
break;
|
||||||
|
case "underline":
|
||||||
|
textContent = `<u>${textContent}</u>`;
|
||||||
|
break;
|
||||||
|
case "subscript":
|
||||||
|
textContent = `<sub>${textContent}</sub>`;
|
||||||
|
break;
|
||||||
|
case "superscript":
|
||||||
|
textContent = `<sup>${textContent}</sup>`;
|
||||||
|
break;
|
||||||
|
case "highlight":
|
||||||
|
const color = mark.attrs?.color || "yellow";
|
||||||
|
textContent = `<mark style="background-color: ${color}">${textContent}</mark>`;
|
||||||
|
break;
|
||||||
|
case "textStyle":
|
||||||
|
if (mark.attrs?.color) {
|
||||||
|
textContent = `<span style="color: ${mark.attrs.color}">${textContent}</span>`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return textContent;
|
||||||
|
|
||||||
|
case "codeBlock":
|
||||||
|
const language = node.attrs?.language || "";
|
||||||
|
const code = nodeContent.map(processNode).join("");
|
||||||
|
return "```" + language + "\n" + code + "\n```";
|
||||||
|
|
||||||
|
case "bulletList":
|
||||||
|
return nodeContent
|
||||||
|
.map((item: any) => processListItem(item, "-"))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
case "orderedList":
|
||||||
|
return nodeContent
|
||||||
|
.map((item: any, index: number) =>
|
||||||
|
processListItem(item, `${index + 1}.`),
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
case "taskList":
|
||||||
|
return nodeContent.map((item: any) => processTaskItem(item)).join("\n");
|
||||||
|
|
||||||
|
case "taskItem":
|
||||||
|
const checked = node.attrs?.checked || false;
|
||||||
|
const checkbox = checked ? "[x]" : "[ ]";
|
||||||
|
return `- ${checkbox} ${nodeContent.map(processNode).join("\n")}`;
|
||||||
|
|
||||||
|
case "listItem":
|
||||||
|
return nodeContent.map(processNode).join("\n");
|
||||||
|
|
||||||
|
case "blockquote":
|
||||||
|
return nodeContent.map((n: any) => "> " + processNode(n)).join("\n");
|
||||||
|
|
||||||
|
case "horizontalRule":
|
||||||
|
return "---";
|
||||||
|
|
||||||
|
case "hardBreak":
|
||||||
|
return "\n";
|
||||||
|
|
||||||
|
case "image":
|
||||||
|
const imgAlt = node.attrs?.alt || "";
|
||||||
|
const imgSrc = node.attrs?.src || "";
|
||||||
|
const imgCaption = node.attrs?.caption || "";
|
||||||
|
return `${imgCaption ? `\n*${imgCaption}*` : ""}`;
|
||||||
|
|
||||||
|
case "video":
|
||||||
|
const videoSrc = node.attrs?.src || "";
|
||||||
|
return `🎥 [Video](${videoSrc})`;
|
||||||
|
|
||||||
|
case "youtube":
|
||||||
|
const youtubeUrl = node.attrs?.src || "";
|
||||||
|
return `📺 [YouTube Video](${youtubeUrl})`;
|
||||||
|
|
||||||
|
case "table":
|
||||||
|
return nodeContent.map(processNode).join("\n");
|
||||||
|
|
||||||
|
case "tableRow":
|
||||||
|
return "| " + nodeContent.map(processNode).join(" | ") + " |";
|
||||||
|
|
||||||
|
case "tableCell":
|
||||||
|
case "tableHeader":
|
||||||
|
return nodeContent.map(processNode).join("");
|
||||||
|
|
||||||
|
case "callout":
|
||||||
|
const calloutType = node.attrs?.type || "info";
|
||||||
|
const calloutContent = nodeContent.map(processNode).join("\n");
|
||||||
|
return `> **${calloutType.toUpperCase()}**\n> ${calloutContent.replace(/\n/g, "\n> ")}`;
|
||||||
|
|
||||||
|
case "details":
|
||||||
|
return nodeContent.map(processNode).join("\n");
|
||||||
|
|
||||||
|
case "detailsSummary":
|
||||||
|
const summaryText = nodeContent.map(processNode).join("");
|
||||||
|
return `<details>\n<summary>${summaryText}</summary>\n`;
|
||||||
|
|
||||||
|
case "detailsContent":
|
||||||
|
const detailsText = nodeContent.map(processNode).join("\n");
|
||||||
|
return `${detailsText}\n</details>`;
|
||||||
|
|
||||||
|
case "mathInline":
|
||||||
|
const inlineMath = node.attrs?.latex || "";
|
||||||
|
return `$${inlineMath}$`;
|
||||||
|
|
||||||
|
case "mathBlock":
|
||||||
|
const blockMath = node.attrs?.latex || "";
|
||||||
|
return `$$\n${blockMath}\n$$`;
|
||||||
|
|
||||||
|
case "mention":
|
||||||
|
const mentionLabel = node.attrs?.label || node.attrs?.id || "";
|
||||||
|
return `@${mentionLabel}`;
|
||||||
|
|
||||||
|
case "attachment":
|
||||||
|
const attachmentName = node.attrs?.fileName || "attachment";
|
||||||
|
const attachmentUrl = node.attrs?.src || "";
|
||||||
|
return `📎 [${attachmentName}](${attachmentUrl})`;
|
||||||
|
|
||||||
|
case "drawio":
|
||||||
|
return `📊 [Draw.io Diagram]`;
|
||||||
|
|
||||||
|
case "excalidraw":
|
||||||
|
return `✏️ [Excalidraw Drawing]`;
|
||||||
|
|
||||||
|
case "embed":
|
||||||
|
const embedUrl = node.attrs?.src || "";
|
||||||
|
return `🔗 [Embedded Content](${embedUrl})`;
|
||||||
|
|
||||||
|
case "subpages":
|
||||||
|
return `📑 [Subpages List]`;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Fallback: process children
|
||||||
|
return nodeContent.map(processNode).join("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processListItem = (item: any, prefix: string): string => {
|
||||||
|
const itemContent = item.content || [];
|
||||||
|
const lines = itemContent.map(processNode);
|
||||||
|
return lines
|
||||||
|
.map((line: string, i: number) =>
|
||||||
|
i === 0 ? `${prefix} ${line}` : ` ${line}`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
const processTaskItem = (item: any): string => {
|
||||||
|
const checked = item.attrs?.checked || false;
|
||||||
|
const checkbox = checked ? "[x]" : "[ ]";
|
||||||
|
const itemContent = item.content || [];
|
||||||
|
const text = itemContent.map(processNode).join("");
|
||||||
|
return `- ${checkbox} ${text}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return processNode(content).trim();
|
||||||
|
}
|
||||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"outDir": "./build",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user