diff --git a/README.md b/README.md index 6cb0ac6..0514ed6 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A Model Context Protocol (MCP) server for [Docmost](https://docmost.com/), enabl - **`create_page`**: Smart creation tool. Creates content (via import) AND handles hierarchy (nesting under a parent) in one go. - **`update_page`**: Update a page's content and/or title. Updates are performed via real-time collaboration (WebSocket). - **`delete_page` / `delete_pages`**: Delete single or multiple pages at once. +- **`upload_file`**: Upload an image or file (base64, data URIs also accepted) to a page. The MIME type is auto-detected (magic bytes, then extension). Images are inserted as image blocks and other files as attachments at the end of the page (`insert: false` to only upload). Request bodies up to 50MB are accepted (`MCP_MAX_BODY_MB` to change). - **`move_page`**: Organize pages hierarchically by moving them to a new parent or root. ### Exploration & Retrieval diff --git a/package.json b/package.json index 341bf7b..1c4dc8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "docmost-mcp", - "version": "1.0.1", + "version": "1.1.0", "description": "A Model Context Protocol (MCP) server for Docmost, allowing AI agents to manage documentation spaces and pages.", "main": "build/index.js", "bin": { diff --git a/src/index.ts b/src/index.ts index 8f609d4..8aa56c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,11 @@ import { markdownToTiptapJson } from "./lib/markdown-to-json.js"; import { readFileSync } from "fs"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; -import { updatePageContentRealtime } from "./lib/collaboration.js"; +import { + updatePageContentRealtime, + appendNodeRealtime, +} from "./lib/collaboration.js"; +import { detectMimeType } from "./lib/mime.js"; import { getCollabToken, performLogin } from "./lib/auth-utils.js"; // Read version from package.json @@ -280,6 +284,98 @@ class DocmostClient { }; } + /** + * Upload a file (base64) as an attachment of a page and, optionally, + * insert it at the end of the page as an image or attachment node. + * The MIME type is detected from the content/extension. + */ + async uploadFile( + pageId: string, + fileName: string, + base64: string, + insert: boolean = true, + mimeType?: string, + ) { + await this.ensureAuthenticated(); + + // Accept raw base64 or a data URI ("data:image/png;base64,....") + const dataUri = base64.match(/^data:([^;,]+)?(?:;[^,]*)?,/); + const raw = dataUri ? base64.slice(dataUri[0].length) : base64; + const buffer = Buffer.from(raw.replace(/\s/g, ""), "base64"); + if (buffer.length === 0) { + throw new Error("The provided base64 content is empty or invalid."); + } + + const mime = mimeType || detectMimeType(buffer, fileName); + + // pageId must precede the file part for Docmost's multipart parser + const form = new FormData(); + form.append("pageId", pageId); + form.append("file", buffer, { filename: fileName, contentType: mime }); + + const response = await axios.post(`${API_URL}/files/upload`, form, { + headers: { + ...form.getHeaders(), + Authorization: `Bearer ${this.token}`, + }, + maxBodyLength: Infinity, + maxContentLength: Infinity, + }); + const attachment = response.data?.data ?? response.data; + const attachmentId = attachment?.id; + if (!attachmentId) { + throw new Error("Upload succeeded but no attachment ID was returned."); + } + + const storedName = attachment.fileName || fileName; + const url = `/api/files/${attachmentId}/${encodeURIComponent(storedName)}`; + const isImage = mime.startsWith("image/"); + + if (insert) { + const node = isImage + ? { + type: "image", + attrs: { + src: url, + alt: storedName, + attachmentId, + size: buffer.length, + }, + } + : { + type: "attachment", + attrs: { + url, + name: storedName, + mime, + size: buffer.length, + attachmentId, + }, + }; + + const baseURL = this.client.defaults.baseURL || ""; + try { + const collabToken = await getCollabToken(baseURL, this.token!); + await appendNodeRealtime(pageId, node, collabToken, baseURL); + } catch (error: any) { + throw new Error( + `File uploaded (attachmentId ${attachmentId}) but inserting it into the page failed: ${error.message}`, + ); + } + } + + return { + success: true, + attachmentId, + fileName: storedName, + mimeType: mime, + size: buffer.length, + url, + type: isImage ? "image" : "attachment", + inserted: insert, + }; + } + async search(query: string, spaceId?: string) { await this.ensureAuthenticated(); const response = await this.client.post("/search", { @@ -628,6 +724,44 @@ server.registerTool( }, ); +// Tool: upload_file +server.registerTool( + "upload_file", + { + description: + "Upload an image or file (base64) to a page. The MIME type is auto-detected: images are inserted as image blocks, any other file as an attachment, appended at the end of the page.", + inputSchema: { + pageId: z.string().describe("ID of the page to attach the file to"), + fileName: z + .string() + .describe("File name including extension (e.g. 'diagram.png')"), + base64: z + .string() + .describe("File content encoded in base64 (a data URI is also accepted)"), + insert: z + .boolean() + .optional() + .describe( + "Insert the file at the end of the page (default true). If false, it is only uploaded.", + ), + mimeType: z + .string() + .optional() + .describe("Optional MIME type override; auto-detected when omitted"), + }, + }, + async ({ pageId, fileName, base64, insert, mimeType }) => { + const result = await docmostClient.uploadFile( + pageId, + fileName, + base64, + insert ?? true, + mimeType, + ); + return jsonContent(result); + }, +); + // Tool: move_page server.registerTool( "move_page", @@ -859,6 +993,8 @@ server.registerTool( }, ); +const MAX_BODY_BYTES = + parseInt(process.env.MCP_MAX_BODY_MB ?? "50", 10) * 1024 * 1024; const NUM_WORKERS = parseInt(process.env.MCP_WORKERS ?? "4", 10); if (cluster.isPrimary) { @@ -882,7 +1018,31 @@ if (cluster.isPrimary) { }); await server.connect(transport); try { - await transport.handleRequest(req, res); + // The SDK caps request bodies at 4MB; base64 uploads need more. + let parsedBody: unknown; + if (req.method === "POST") { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of req) { + total += chunk.length; + if (total > MAX_BODY_BYTES) { + res.writeHead(413, { "Content-Type": "application/json" }).end( + JSON.stringify({ jsonrpc: "2.0", error: { code: -32600, message: `Request body exceeds ${MAX_BODY_BYTES} bytes` }, id: null }) + ); + return; + } + chunks.push(chunk); + } + try { + parsedBody = JSON.parse(Buffer.concat(chunks).toString("utf-8")); + } catch { + res.writeHead(400, { "Content-Type": "application/json" }).end( + JSON.stringify({ jsonrpc: "2.0", error: { code: -32700, message: "Parse error" }, id: null }) + ); + return; + } + } + await transport.handleRequest(req, res, parsedBody); } finally { await transport.close(); } diff --git a/src/lib/collaboration.ts b/src/lib/collaboration.ts index 75a592b..1a5a9b4 100644 --- a/src/lib/collaboration.ts +++ b/src/lib/collaboration.ts @@ -133,3 +133,79 @@ export async function updatePageContentRealtime( }); }); } + +/** + * Append a leaf block node (e.g. `image` or `attachment`) at the end of a page + * through the collaboration server, leaving the rest of the document untouched. + * + * The node is written directly as a Y.XmlElement (ProseMirror node name + attrs), + * which is how y-prosemirror represents leaf nodes, so no TipTap schema is needed + * and unknown Docmost node types already in the page are not affected. + */ +export async function appendNodeRealtime( + pageId: string, + node: { type: string; attrs: Record }, + collabToken: string, + baseUrl: string, +): Promise { + const ydoc = new Y.Doc(); + + let wsUrl = baseUrl.replace(/^http/, "ws"); + try { + const urlObj = new URL(wsUrl); + urlObj.pathname = + urlObj.pathname.replace(/\/api\/?$/, "").replace(/\/$/, "") + "/collab"; + wsUrl = urlObj.toString(); + } catch (e) { + wsUrl = wsUrl.replace(/\/api\/?$/, "").replace(/\/$/, "") + "/collab"; + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + provider.destroy(); + reject(new Error("Connection timeout to collaboration server")); + }, 120000); + + const provider = new HocuspocusProvider({ + url: wsUrl, + name: `page.${pageId}`, + document: ydoc, + token: collabToken, + // @ts-ignore - Required for Node.js environment + WebSocketPolyfill: WebSocket as any, + onSynced: () => { + try { + const element = new Y.XmlElement(node.type); + for (const [key, value] of Object.entries(node.attrs)) { + if (value !== null && value !== undefined) { + element.setAttribute(key, value); + } + } + const fragment = ydoc.getXmlFragment("default"); + ydoc.transact(() => { + fragment.push([element]); + }); + + clearTimeout(timer); + resolve(); + + // Keep the connection open so Docmost can persist (10s debounce) + setTimeout(() => { + try { + 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")); + }, + }); + }); +} diff --git a/src/lib/mime.ts b/src/lib/mime.ts new file mode 100644 index 0000000..4e5670e --- /dev/null +++ b/src/lib/mime.ts @@ -0,0 +1,87 @@ +/** + * MIME type detection for uploaded files. + * Detection order: magic bytes -> file extension -> application/octet-stream. + */ + +const SIGNATURES: Array<{ mime: string; offset?: number; bytes: number[] }> = [ + { mime: "image/png", bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }, + { mime: "image/jpeg", bytes: [0xff, 0xd8, 0xff] }, + { mime: "image/gif", bytes: [0x47, 0x49, 0x46, 0x38] }, + { mime: "image/bmp", bytes: [0x42, 0x4d] }, + { mime: "application/pdf", bytes: [0x25, 0x50, 0x44, 0x46] }, + // ZIP container (also docx/xlsx/pptx; refined by extension below) + { mime: "application/zip", bytes: [0x50, 0x4b, 0x03, 0x04] }, + { mime: "application/gzip", bytes: [0x1f, 0x8b] }, +]; + +const EXTENSIONS: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + avif: "image/avif", + bmp: "image/bmp", + svg: "image/svg+xml", + ico: "image/x-icon", + pdf: "application/pdf", + txt: "text/plain", + md: "text/markdown", + csv: "text/csv", + json: "application/json", + xml: "application/xml", + html: "text/html", + zip: "application/zip", + gz: "application/gzip", + tar: "application/x-tar", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ppt: "application/vnd.ms-powerpoint", + pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + mp3: "audio/mpeg", + wav: "audio/wav", + mp4: "video/mp4", + webm: "video/webm", + mov: "video/quicktime", +}; + +function startsWith(buf: Buffer, bytes: number[], offset = 0): boolean { + if (buf.length < offset + bytes.length) return false; + return bytes.every((b, i) => buf[offset + i] === b); +} + +function extensionOf(fileName: string): string { + const idx = fileName.lastIndexOf("."); + return idx === -1 ? "" : fileName.slice(idx + 1).toLowerCase(); +} + +export function detectMimeType(buffer: Buffer, fileName: string): string { + // WebP: "RIFF" .... "WEBP" + if ( + startsWith(buffer, [0x52, 0x49, 0x46, 0x46]) && + startsWith(buffer, [0x57, 0x45, 0x42, 0x50], 8) + ) { + return "image/webp"; + } + + const fromExt = EXTENSIONS[extensionOf(fileName)]; + const sig = SIGNATURES.find((s) => startsWith(buffer, s.bytes, s.offset)); + + if (sig) { + // Office documents are ZIP containers: trust a more specific extension. + if (sig.mime === "application/zip" && fromExt) return fromExt; + return sig.mime; + } + + if (fromExt) return fromExt; + + // SVG has no magic bytes: sniff the start of the text. + const head = buffer.subarray(0, 512).toString("utf-8").trimStart(); + if (head.startsWith("