diff --git a/README.md b/README.md
index 34b1bdd..1c8b4c1 100644
--- a/README.md
+++ b/README.md
@@ -241,6 +241,7 @@ Both templates support `id` autocompletion: as you type, the server searches Boo
| `get_chapters` / `get_chapter` | List or get chapter details |
| `get_shelves` / `get_shelf` | List or get shelf details |
| `get_attachments` / `get_attachment` | List or get attachment details |
+| `get_images` / `get_image` | List or get gallery images, with ready-to-embed html/markdown snippets |
| `get_comments` / `get_comment` | List or get page comments (BookStack v25.11+) |
| `find_users` | Look up BookStack users by name, email, or slug to resolve user slugs for `{created_by:X}`-style search filters |
| `get_recycle_bin` | List items in the recycle bin |
@@ -260,9 +261,46 @@ Both templates support `id` autocompletion: as you type, the server searches Boo
| `delete_page` | Delete a page (recoverable from recycle bin) |
| `create_shelf` / `update_shelf` / `delete_shelf` | Manage shelves |
| `create_attachment` / `update_attachment` / `delete_attachment` | Manage attachments |
+| `create_image` / `delete_image` | Upload an image into the gallery so it can be embedded in a page, or delete one ([details](#embedding-images-in-pages)) |
| `create_comment` / `update_comment` / `delete_comment` | Manage page comments (v25.11+) |
| `restore_deleted` / `permanently_delete` | Restore or permanently destroy items in the recycle bin |
+### Embedding images in pages
+
+Attachments hang files off a page; **gallery images** are what page content can
+actually reference. `create_image` uploads a local file into the gallery and
+returns the hosted URL along with `content.markdown` and `content.html` snippets,
+which you then paste into `create_page` / `update_page`:
+
+```
+create_image(file_path="/tmp/rack-diagram.png", uploaded_to=42)
+ -> { "id": 91, "url": "https://wiki.example/uploads/images/gallery/...png",
+ "content": { "markdown": "", "html": "
" } }
+
+update_page(id=42, markdown="## Rack layout\n\n")
+```
+
+The model passes a **path**, not the bytes: the image never enters the
+conversation, so a 2 MB screenshot costs a few dozen tokens instead of ~2.7 MB of
+base64. This is the reason to prefer it over inlining a `data:` URI.
+
+Notes:
+
+- BookStack accepts `jpg`, `jpeg`, `png`, `gif`, `webp`, `avif`. **SVG is
+ rejected** for gallery images — convert to PNG first.
+- `content.markdown` / `content.html` reference the **display-scaled** variant
+ (`.../gallery//scaled-1680-/`), while `url` is the original
+ upload. Embed the snippet as given unless you specifically want full
+ resolution, in which case use `url`.
+- `uploaded_to` is required by BookStack; every gallery image belongs to a page.
+- The token's user needs the **“Manage image library”** role permission
+ (`image-create-all`) *and* edit rights on the target page, or the upload comes
+ back 403.
+- `create_image` is **stdio-only**. It reads a file from the machine running the
+ server, which only means anything when that machine is the caller's own. Over
+ HTTP the server is remote and shared, so the tool is not registered at all —
+ exposing it there would be an arbitrary-file-read primitive against the host.
+
## BookStack API Setup
1. Log into BookStack as an admin
diff --git a/src/bookstack-client.ts b/src/bookstack-client.ts
index a3d532d..51b2926 100644
--- a/src/bookstack-client.ts
+++ b/src/bookstack-client.ts
@@ -1,7 +1,10 @@
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig, AxiosAdapter } from 'axios';
import https from 'https';
+import { readFile } from 'node:fs/promises';
+import { isAbsolute } from 'node:path';
import { Semaphore } from './util/semaphore.js';
import { countWords } from './util/word-count.js';
+import { resolveImageUpload } from './util/image-upload.js';
const MAX_RETRIES_429 = 5;
@@ -34,6 +37,26 @@ function validateUserIdFilters(query: string): void {
}
}
+/**
+ * Re-throw an axios failure with BookStack's response body attached. Uploads fail
+ * validation far more often than JSON calls (format, size, permissions, a page the
+ * token cannot edit), and a bare "Request failed with status code 422" hides the
+ * one thing that says which rule tripped.
+ */
+function rethrowWithApiDetail(error: unknown, context: string): never {
+ const axiosError = error as AxiosError;
+ const status = axiosError?.response?.status;
+ if (!status) throw error;
+
+ const body = axiosError.response?.data as any;
+ const detail =
+ body?.error?.message ??
+ (body?.error?.validation && JSON.stringify(body.error.validation)) ??
+ (typeof body === 'string' ? body : body && JSON.stringify(body));
+
+ throw new Error(`${context} failed (HTTP ${status})${detail ? `: ${detail}` : ''}`);
+}
+
export interface BookStackConfig {
baseUrl: string;
tokenId: string;
@@ -121,6 +144,28 @@ export interface Attachment {
};
}
+export interface Image {
+ id: number;
+ name: string;
+ url: string;
+ path: string;
+ type: string;
+ uploaded_to: number;
+ created_at: string;
+ updated_at: string;
+ created_by: number | { id: number; name: string };
+ updated_by: number | { id: number; name: string };
+ /** Ready-to-embed snippets BookStack renders for this image. */
+ content?: {
+ html: string;
+ markdown: string;
+ };
+ thumbs?: {
+ gallery?: string;
+ display?: string;
+ };
+}
+
export interface User {
id: number;
name: string;
@@ -1052,6 +1097,100 @@ export class BookStackClient {
return response.data;
}
+ // Image gallery — the images embedded in page content, as opposed to
+ // attachments (files hanging off a page). Uploading here is what makes an
+ // image referencable from page HTML/markdown.
+ async getImages(options?: {
+ uploadedTo?: number;
+ offset?: number;
+ count?: number;
+ sort?: string;
+ }): Promise> {
+ const params: any = {
+ offset: options?.offset || 0,
+ count: Math.min(options?.count || 50, 500)
+ };
+ if (options?.uploadedTo) params['filter[uploaded_to]'] = options.uploadedTo;
+ if (options?.sort) params.sort = options.sort;
+
+ const response = await this.client.get('/image-gallery', { params });
+ return response.data;
+ }
+
+ async getImage(id: number): Promise {
+ const response = await this.client.get(`/image-gallery/${id}`);
+ return response.data;
+ }
+
+ /**
+ * Upload a local image file into the gallery and associate it with a page.
+ *
+ * The multipart body goes through the shared axios instance on purpose, so the
+ * upload inherits the concurrency semaphore, the 429 retry/backoff, the request
+ * timeout and the TLS opt-out. That requires overriding `Content-Type` to
+ * undefined for this request: the instance default is `application/json`, and
+ * with it in place axios never reaches its form-data serializer — it JSON-encodes
+ * the FormData instead, silently flattening the file to `"image":{}` and failing
+ * validation server-side. Setting the header to undefined lets axios pick the
+ * multipart serializer and generate the boundary.
+ */
+ async createImage(data: {
+ filePath: string;
+ uploadedTo: number;
+ name?: string;
+ type?: 'gallery' | 'drawio';
+ }): Promise {
+ if (!this.enableWrite) {
+ throw new Error('Write operations are disabled. Set BOOKSTACK_ENABLE_WRITE=true to enable.');
+ }
+ if (!isAbsolute(data.filePath)) {
+ throw new Error(
+ `file_path must be an absolute path, got '${data.filePath}'. The MCP server resolves it ` +
+ `from its own working directory, not the caller's, and does not expand '~'.`
+ );
+ }
+
+ let bytes: Buffer;
+ try {
+ bytes = await readFile(data.filePath);
+ } catch (err) {
+ throw new Error(`Cannot read image at ${data.filePath}: ${(err as Error).message}`);
+ }
+
+ const resolved = resolveImageUpload({
+ filePath: data.filePath,
+ byteLength: bytes.byteLength,
+ name: data.name
+ });
+
+ const form = new FormData();
+ form.append('type', data.type ?? 'gallery');
+ form.append('uploaded_to', String(data.uploadedTo));
+ form.append('name', resolved.name);
+ form.append(
+ 'image',
+ new Blob([new Uint8Array(bytes)], { type: resolved.mimeType }),
+ resolved.filename
+ );
+
+ try {
+ const response = await this.client.post('/image-gallery', form, {
+ headers: { 'Content-Type': undefined }
+ });
+ return response.data;
+ } catch (err) {
+ rethrowWithApiDetail(err, `Image upload to page ${data.uploadedTo}`);
+ }
+ }
+
+ async deleteImage(id: number): Promise {
+ if (!this.enableWrite) {
+ throw new Error('Write operations are disabled. Set BOOKSTACK_ENABLE_WRITE=true to enable.');
+ }
+ const response = await this.client.delete(`/image-gallery/${id}`);
+ return response.data;
+ }
+
// Comments (BookStack v25.11+)
async getComments(options?: {
pageId?: number;
diff --git a/src/index.ts b/src/index.ts
index f525d4c..9934bf5 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -60,14 +60,31 @@ function getRequiredEnvVar(name: string): string {
return value;
}
-function buildServer(config: BookStackConfig): McpServer {
+/**
+ * Server-side capabilities that depend on the transport, not on the BookStack
+ * credential. Kept separate from BookStackConfig because the HTTP path reuses the
+ * very same config objects for every session — a flag stored there would leak into
+ * remote sessions. Omission means "off", so HTTP is safe by default.
+ */
+interface ServerCapabilities {
+ /**
+ * Whether tools may read files from the machine running this server. Only true
+ * on stdio, where the server is a child process of the caller and its filesystem
+ * IS the caller's. Over HTTP the server is remote and shared: a caller-supplied
+ * path would be an arbitrary-file-read primitive against the host (and would not
+ * refer to anything the caller can see anyway).
+ */
+ localFileUploads?: boolean;
+}
+
+function buildServer(config: BookStackConfig, capabilities: ServerCapabilities = {}): McpServer {
const client = new BookStackClient(config);
const server = new McpServer({
name: "bookstack-mcp",
version: PKG_VERSION
});
- registerTools(server, client, config);
+ registerTools(server, client, config, capabilities);
registerResources(server, client);
registerPrompts(server);
return server;
@@ -175,7 +192,12 @@ function registerResources(server: McpServer, client: BookStackClient): void {
);
}
-function registerTools(server: McpServer, client: BookStackClient, config: BookStackConfig): void {
+function registerTools(
+ server: McpServer,
+ client: BookStackClient,
+ config: BookStackConfig,
+ capabilities: ServerCapabilities = {}
+): void {
// Helpers wrap registerTool and inject MCP tool annotations so clients can
// distinguish read-only from destructive operations. Typed loosely to defer
// to the SDK's generic overloads at the call sites.
@@ -509,6 +531,46 @@ function registerTools(server: McpServer, client: BookStackClient, config: BookS
}
);
+ readTool(
+ "get_images",
+ {
+ description: "List gallery images (the images embedded in page content, not attachments). Filter by uploaded_to to get one page's images.",
+ inputSchema: {
+ uploaded_to: z.coerce.number().optional().describe("Only images attached to this page ID"),
+ offset: z.coerce.number().optional().default(0),
+ count: z.coerce.number().max(500).optional().default(50),
+ sort: z.string().optional()
+ }
+ },
+ async (args) => {
+ const images = await client.getImages({
+ uploadedTo: args.uploaded_to,
+ offset: args.offset,
+ count: args.count,
+ sort: args.sort
+ });
+ return {
+ content: [{ type: "text", text: JSON.stringify(images) }]
+ };
+ }
+ );
+
+ readTool(
+ "get_image",
+ {
+ description: "Get a gallery image, including its url and ready-to-embed html/markdown snippets.",
+ inputSchema: {
+ id: z.coerce.number().min(1)
+ }
+ },
+ async (args) => {
+ const image = await client.getImage(args.id);
+ return {
+ content: [{ type: "text", text: JSON.stringify(image) }]
+ };
+ }
+ );
+
readTool(
"get_attachment",
{
@@ -912,6 +974,54 @@ function registerTools(server: McpServer, client: BookStackClient, config: BookS
}
);
+ // Uploading reads a file from this machine, so it only exists on stdio.
+ // See ServerCapabilities.localFileUploads.
+ if (capabilities.localFileUploads) {
+ writeTool(
+ "create_image",
+ {
+ description:
+ "Upload a local image file into BookStack's image gallery so it can be embedded in page content. " +
+ "Pass the path — the image bytes never pass through the conversation. Returns the hosted url plus " +
+ "content.markdown and content.html snippets to paste into create_page/update_page. " +
+ "Accepts jpg, jpeg, png, gif, webp, avif (not svg).",
+ inputSchema: {
+ file_path: z.string().describe("Absolute path to the image on the machine running this server ('~' is not expanded)"),
+ uploaded_to: z.coerce.number().min(1).describe("Page ID to associate the image with; BookStack requires one"),
+ name: z.string().optional().describe("Gallery display name (defaults to the filename)"),
+ type: z.enum(["gallery", "drawio"]).optional().describe("'gallery' for normal images (default), 'drawio' for a diagrams.net PNG")
+ }
+ },
+ async (args) => {
+ const image = await client.createImage({
+ filePath: args.file_path,
+ uploadedTo: args.uploaded_to,
+ name: args.name,
+ type: args.type
+ });
+ return {
+ content: [{ type: "text", text: JSON.stringify(image) }]
+ };
+ }
+ );
+ }
+
+ writeTool(
+ "delete_image",
+ {
+ description: "Delete a gallery image. Pages still referencing it will show a broken image.",
+ inputSchema: {
+ id: z.coerce.number().min(1)
+ }
+ },
+ async (args) => {
+ const result = await client.deleteImage(args.id);
+ return {
+ content: [{ type: "text", text: JSON.stringify(result) }]
+ };
+ }
+ );
+
writeTool(
"delete_book",
{
@@ -1057,7 +1167,9 @@ function registerTools(server: McpServer, client: BookStackClient, config: BookS
}
async function startStdio(config: AppConfig): Promise {
- const server = buildServer(config.read);
+ // On stdio the server runs as a child of the caller, under the caller's own
+ // account — reading a file it names crosses no trust boundary.
+ const server = buildServer(config.read, { localFileUploads: true });
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("BookStack MCP server running on stdio");
diff --git a/src/util/image-upload.test.ts b/src/util/image-upload.test.ts
new file mode 100644
index 0000000..a8b6175
--- /dev/null
+++ b/src/util/image-upload.test.ts
@@ -0,0 +1,80 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ basenameOf,
+ resolveImageUpload,
+ DEFAULT_MAX_UPLOAD_KIB,
+ SUPPORTED_IMAGE_EXTENSIONS,
+} from './image-upload.js';
+
+const ok = { filePath: '/tmp/shot.png', byteLength: 1024 };
+
+test('derives filename and mime type from the extension', () => {
+ const r = resolveImageUpload(ok);
+ assert.equal(r.filename, 'shot.png');
+ assert.equal(r.mimeType, 'image/png');
+});
+
+test('defaults the gallery name to the filename', () => {
+ assert.equal(resolveImageUpload(ok).name, 'shot.png');
+});
+
+test('an explicit name wins, trimmed', () => {
+ assert.equal(resolveImageUpload({ ...ok, name: ' Rack diagram ' }).name, 'Rack diagram');
+});
+
+test('a blank name falls back to the filename rather than uploading an empty name', () => {
+ assert.equal(resolveImageUpload({ ...ok, name: ' ' }).name, 'shot.png');
+});
+
+test('both jpg and jpeg map to image/jpeg (mimes:jpeg matches either)', () => {
+ assert.equal(resolveImageUpload({ ...ok, filePath: 'a.jpg' }).mimeType, 'image/jpeg');
+ assert.equal(resolveImageUpload({ ...ok, filePath: 'a.jpeg' }).mimeType, 'image/jpeg');
+});
+
+test('accepts avif, which BookStack allows', () => {
+ assert.equal(resolveImageUpload({ ...ok, filePath: 'a.avif' }).mimeType, 'image/avif');
+});
+
+test('rejects svg, which BookStack does NOT allow for gallery images', () => {
+ assert.throws(() => resolveImageUpload({ ...ok, filePath: 'diagram.svg' }), /SVG is not accepted/);
+});
+
+test('rejects a file with no extension', () => {
+ assert.throws(() => resolveImageUpload({ ...ok, filePath: '/tmp/screenshot' }), /no extension/);
+});
+
+test('extension matching is case-insensitive', () => {
+ assert.equal(resolveImageUpload({ ...ok, filePath: 'A.PNG' }).mimeType, 'image/png');
+});
+
+test('strips directories, including Windows separators', () => {
+ assert.equal(basenameOf('/a/b/c.png'), 'c.png');
+ assert.equal(basenameOf('C:\\shots\\c.png'), 'c.png');
+ assert.equal(basenameOf('c.png'), 'c.png');
+});
+
+test('rejects an empty file instead of uploading zero bytes', () => {
+ assert.throws(() => resolveImageUpload({ ...ok, byteLength: 0 }), /empty file/);
+});
+
+test('rejects a file above the default upload limit', () => {
+ const tooBig = (DEFAULT_MAX_UPLOAD_KIB + 1) * 1024;
+ assert.throws(() => resolveImageUpload({ ...ok, byteLength: tooBig }), /upload limit/);
+});
+
+test('accepts a file exactly at the default upload limit', () => {
+ const atLimit = DEFAULT_MAX_UPLOAD_KIB * 1024;
+ assert.equal(resolveImageUpload({ ...ok, byteLength: atLimit }).filename, 'shot.png');
+});
+
+test('the unsupported-format error names every accepted extension', () => {
+ try {
+ resolveImageUpload({ ...ok, filePath: 'a.bmp' });
+ assert.fail('expected a throw');
+ } catch (err) {
+ for (const ext of SUPPORTED_IMAGE_EXTENSIONS) {
+ assert.ok((err as Error).message.includes(ext), `message should mention ${ext}`);
+ }
+ }
+});
diff --git a/src/util/image-upload.ts b/src/util/image-upload.ts
new file mode 100644
index 0000000..f6f55cf
--- /dev/null
+++ b/src/util/image-upload.ts
@@ -0,0 +1,93 @@
+/**
+ * Pure helpers for gallery image uploads. Kept free of I/O so the format and
+ * size contract can be unit-tested without a BookStack instance or a filesystem.
+ */
+
+/**
+ * Extensions BookStack's image gallery accepts, mirroring the server's
+ * `getImageValidationRules()` (`app/Http/Controller.php`):
+ *
+ * ['image_extension', 'mimes:jpeg,png,gif,webp,avif', 'max:' . (config('app.upload_limit') * 1000)]
+ *
+ * Note SVG is NOT accepted (BookStack rejects it for gallery images) and AVIF
+ * is. `jpg` is included because `mimes:jpeg` matches both spellings.
+ */
+const EXTENSION_MIME_TYPES: ReadonlyMap = new Map([
+ ['jpg', 'image/jpeg'],
+ ['jpeg', 'image/jpeg'],
+ ['png', 'image/png'],
+ ['gif', 'image/gif'],
+ ['webp', 'image/webp'],
+ ['avif', 'image/avif'],
+]);
+
+export const SUPPORTED_IMAGE_EXTENSIONS: readonly string[] = [...EXTENSION_MIME_TYPES.keys()];
+
+/**
+ * BookStack's default `app.upload_limit` is 50 (MB), which Laravel validates as
+ * `max:50000` — kilobytes, i.e. 50000 * 1024 bytes. Instances may raise or lower
+ * it, so this is only a fail-fast guard against the obvious case: the server
+ * stays authoritative and a smaller server limit still returns a 422.
+ */
+export const DEFAULT_MAX_UPLOAD_KIB = 50_000;
+
+export interface ResolvedImageUpload {
+ /** Base filename sent as the multipart part filename. */
+ filename: string;
+ /** MIME type derived from the extension, for the multipart part. */
+ mimeType: string;
+ /** Gallery display name — the caller's override, else the filename. */
+ name: string;
+}
+
+/** Strip directories and return the lowercased extension without its dot. */
+function extensionOf(filename: string): string {
+ const dot = filename.lastIndexOf('.');
+ return dot === -1 ? '' : filename.slice(dot + 1).toLowerCase();
+}
+
+export function basenameOf(filePath: string): string {
+ const normalized = filePath.replace(/\\/g, '/');
+ const slash = normalized.lastIndexOf('/');
+ return slash === -1 ? normalized : normalized.slice(slash + 1);
+}
+
+/**
+ * Validate a local image against BookStack's gallery contract and derive the
+ * multipart part metadata. Throws with an actionable message rather than
+ * letting the upload fail as an opaque 422.
+ */
+export function resolveImageUpload(options: {
+ filePath: string;
+ byteLength: number;
+ name?: string;
+}): ResolvedImageUpload {
+ const filename = basenameOf(options.filePath);
+ if (!filename) {
+ throw new Error(`file_path does not name a file: ${options.filePath}`);
+ }
+
+ const extension = extensionOf(filename);
+ const mimeType = EXTENSION_MIME_TYPES.get(extension);
+ if (!mimeType) {
+ throw new Error(
+ `Unsupported image format ${extension ? `'.${extension}'` : `(no extension on '${filename}')`}. ` +
+ `BookStack's image gallery accepts: ${SUPPORTED_IMAGE_EXTENSIONS.join(', ')}. ` +
+ `SVG is not accepted — convert to PNG first.`
+ );
+ }
+
+ if (options.byteLength === 0) {
+ throw new Error(`Refusing to upload an empty file: ${options.filePath}`);
+ }
+
+ const kib = options.byteLength / 1024;
+ if (kib > DEFAULT_MAX_UPLOAD_KIB) {
+ throw new Error(
+ `Image is ${Math.round(kib / 1024)} MiB, above BookStack's default upload limit of ` +
+ `${DEFAULT_MAX_UPLOAD_KIB / 1000} MB. Resize it, or raise app.upload_limit on the server.`
+ );
+ }
+
+ return { filename, mimeType, name: options.name?.trim() || filename };
+}