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": "![rack-diagram.png](...)", "html": "" } } + +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//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 }; +}