feat: add upload_file tool for images and attachments
Docker Build & Push / build-and-push (push) Successful in 14m7s

Upload base64 files to a page with MIME type auto-detection (magic bytes,
then extension). Images are inserted as image blocks and other files as
attachments at the end of the page via realtime collaboration. Request
body limit raised to 50MB (MCP_MAX_BODY_MB).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 14:09:48 -06:00
co-authored by Claude Sonnet 5.5
parent 8332dc9679
commit 456a19ca58
5 changed files with 327 additions and 3 deletions
+1
View File
@@ -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
+1 -1
View File
@@ -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": {
+162 -2
View File
@@ -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();
}
+76
View File
@@ -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<string, any> },
collabToken: string,
baseUrl: string,
): Promise<void> {
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<void>((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"));
},
});
});
}
+87
View File
@@ -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<string, string> = {
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("<svg") || (head.startsWith("<?xml") && head.includes("<svg"))) {
return "image/svg+xml";
}
return "application/octet-stream";
}