Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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": "![rack-diagram.png](...)", "html": "<a href=...><img src=...></a>" } }

update_page(id=42, markdown="## Rack layout\n\n![rack-diagram.png](...)")
```

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/<month>/scaled-1680-/<file>`), 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
Expand Down
139 changes: 139 additions & 0 deletions src/bookstack-client.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<ListResponse<Image>> {
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<Image> {
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<Image> {
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<any> {
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;
Expand Down
120 changes: 116 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -1057,7 +1167,9 @@ function registerTools(server: McpServer, client: BookStackClient, config: BookS
}

async function startStdio(config: AppConfig): Promise<void> {
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");
Expand Down
Loading