From f0cd65dccedb6a7da9eb2b9b783861d0c2f4402a Mon Sep 17 00:00:00 2001 From: gusocegodk Date: Wed, 7 Oct 2026 10:15:19 +0200 Subject: [PATCH 1/2] feat: add comment, import and tag tools and ZIP export - bookstack_comments_{list,create,read,update,delete} (BookStack v25.11+) - bookstack_imports_{list,create,read,run,delete}: upload a portable ZIP and run it into a book or chapter (v25.07+) - bookstack_tags_list_names and bookstack_tags_list_values (v26.05+) - `zip` export format for books, chapters and pages, returned base64 Uploads are capped at 50000 KB in this server: base64 fields publish and enforce maxLength, and file_path must be a regular file within the limit. Upload-guard refusals are validation errors. 71 tools in 16 categories, covered by unit, transport and live integration tests. --- README.md | 20 +- docker-compose.yml | 6 +- docs/api-reference.md | 146 +++++- docs/examples-and-workflows.md | 2 +- docs/integration-testing.md | 2 +- docs/setup-guide.md | 8 +- docs/tools-overview.md | 93 +++- src/api/client.ts | 170 ++++++- src/config/manager.ts | 2 +- src/server.ts | 18 +- src/tools/attachments.ts | 3 + src/tools/books.ts | 14 +- src/tools/chapters.ts | 10 +- src/tools/comments.ts | 402 +++++++++++++++ src/tools/images.ts | 3 + src/tools/imports.ts | 381 +++++++++++++++ src/tools/pages.ts | 10 +- src/tools/server-info.ts | 83 +++- src/tools/tags.ts | 217 +++++++++ src/types.ts | 208 +++++++- src/utils/errors.ts | 15 + src/validation/validator.ts | 205 +++++++- tests/helpers/strict-tools.ts | 6 + tests/integration/attachments.test.ts | 4 +- tests/integration/books.test.ts | 5 +- tests/integration/chapters.test.ts | 5 +- .../integration/comments-tags-imports.test.ts | 218 +++++++++ tests/integration/images.test.ts | 4 +- tests/integration/pages.test.ts | 30 +- tests/transport/docker-smoke.test.ts | 2 +- tests/transport/stdio.test.ts | 4 +- tests/transport/stub-bookstack.ts | 251 +++++++++- tests/transport/tools.test.ts | 460 +++++++++++++++++- tests/unit/comments.test.ts | 147 ++++++ tests/unit/error-handler.test.ts | 16 +- tests/unit/id-schema-contract.test.ts | 46 ++ tests/unit/imports.test.ts | 125 +++++ tests/unit/server-info.test.ts | 29 ++ tests/unit/tags.test.ts | 88 ++++ tests/unit/upload-guard.test.ts | 54 +- tests/unit/upload-limits.test.ts | 99 ++++ 41 files changed, 3485 insertions(+), 126 deletions(-) create mode 100644 src/tools/comments.ts create mode 100644 src/tools/imports.ts create mode 100644 src/tools/tags.ts create mode 100644 tests/integration/comments-tags-imports.test.ts create mode 100644 tests/unit/comments.test.ts create mode 100644 tests/unit/imports.test.ts create mode 100644 tests/unit/tags.test.ts create mode 100644 tests/unit/upload-limits.test.ts diff --git a/README.md b/README.md index 679adad..769ece0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # BookStack MCP Server -Connect BookStack to Claude and other AI assistants through the Model Context Protocol (MCP). This server exposes 59 tools, 5 resources and 6 resource templates covering the supported subset of the BookStack API — books, pages, chapters, shelves, search, users, roles, permissions, attachments, images, the recycle bin, the audit log and system info. +Connect BookStack to Claude and other AI assistants through the Model Context Protocol (MCP). This server exposes 71 tools, 5 resources and 6 resource templates covering the supported subset of the BookStack API — books, pages, chapters, shelves, search, users, roles, permissions, attachments, images, comments, ZIP imports, tags, the recycle bin, the audit log and system info. This server supports two transport modes: **Streamable HTTP** (default) and **Stdio**. @@ -13,7 +13,7 @@ This server supports two transport modes: **Streamable HTTP** (default) and **St ## ✨ What You Get - **BookStack Integration** - Access your books, pages, chapters, and content -- **59 MCP Tools, 5 Resources & 6 Resource Templates** - CRUD, search and export across the supported endpoint families +- **71 MCP Tools, 5 Resources & 6 Resource Templates** - CRUD, search and export across the supported endpoint families - **Search & Export** - Find content and export in multiple formats - **User Management** - Handle users, roles, and permissions - **Production Ready** - Rate limiting, validation, error handling, and logging @@ -44,7 +44,7 @@ bookstack-mcp-server The two tokens are **not** interchangeable and must not be set to the same value: `BOOKSTACK_API_TOKEN` is what the server presents to BookStack; `MCP_AUTH_TOKEN` is -what callers must present to `POST /message`, which dispatches all 59 tools with the +what callers must present to `POST /message`, which dispatches all 71 tools with the authority of the BookStack account behind `BOOKSTACK_API_TOKEN`. Skip `MCP_AUTH_TOKEN` only for [stdio](#-transports), which has no network surface and ignores it. @@ -99,7 +99,7 @@ export MCP_TRANSPORT="http" | `BOOKSTACK_API_TOKEN` | _(none — required, unless `MCP_AUTH_MODE=oauth`)_ | **Outbound** BookStack API token as `token_id:token_secret`. This is the credential the server spends on every tool call. Startup fails if unset. | | `BOOKSTACK_TIMEOUT` | `30000` | BookStack request timeout in milliseconds. | | `BOOKSTACK_ALLOWED_BASE_URLS` | _(none)_ | Comma-separated BookStack API base URLs a caller may name in the `x-bookstack-url` header, compared canonically. Unset refuses that header. See [per-request overrides](#per-request-overrides). | -| `BOOKSTACK_UPLOAD_ROOT` | _(none)_ | Directory the image/attachment tools' `file_path` may read from. Unset refuses `file_path` under every transport, stdio included; the path must resolve inside this directory. Must be unset with `MCP_AUTH_MODE=oauth`. | +| `BOOKSTACK_UPLOAD_ROOT` | _(none)_ | Directory the image, attachment and import tools' `file_path` may read from. Unset refuses `file_path` under every transport, stdio included; the path must resolve inside this directory. Must be unset with `MCP_AUTH_MODE=oauth`. | | `SERVER_PORT` | `3000` | Port the HTTP transport listens on; if it is taken, startup fails with exit code `1`. Ignored in stdio mode. | | `HTTP_BODY_LIMIT` | `73400320` (70 MiB) | Maximum accepted `POST /message` body, in bytes. Sized for the largest inline base64 upload the image/attachment tools advertise (50,000 KB). Express's own default is ~100 KB, which would reject real uploads with a `413`. Lower it if untrusted callers can reach the port. | | `SERVER_NAME` | `bookstack-mcp-server` | Server name reported over MCP and by `GET /`. | @@ -178,7 +178,7 @@ with the failing check named: "status": "unhealthy", "checks": [ { "name": "bookstack_connection", "healthy": false, "message": "BookStack API connection" }, - { "name": "tools_loaded", "healthy": true, "message": "59 tools loaded" }, + { "name": "tools_loaded", "healthy": true, "message": "71 tools loaded" }, { "name": "resources_loaded", "healthy": true, "message": "5 resources and 6 resource templates loaded" } ] } @@ -301,9 +301,9 @@ MCP clients pipe over stdin/stdout. For stdio you also don't need `-p 3000:3000` ## đŸ› ī¸ Available Tools -**59 tools across 13 categories:** +**71 tools across 16 categories:** -- **📚 Books** (6) - Create, read, update, delete, and export books +- **📚 Books** (6) - Create, read, update, delete, and export books (html, pdf, plaintext, markdown, zip) - **📄 Pages** (9) - Manage pages with HTML/Markdown content, including [partial editing](#-partial-page-editing) - **📑 Chapters** (6) - Organize pages within books - **📚 Shelves** (5) - Group books into collections @@ -315,10 +315,12 @@ MCP clients pipe over stdin/stdout. For stdio you also don't need `-p 3000:3000` - **đŸ—‘ī¸ Recycle Bin** (3) - Deleted item recovery - **📎 Attachments** (5) - File attachments - **đŸ–ŧī¸ Images** (5) - Image gallery +- **đŸ’Ŧ Comments** (5) - Page comments, replies and archiving +- **đŸ“Ļ Imports** (5) - Upload and run BookStack ZIP imports +- **đŸˇī¸ Tags** (2) - Tag names and values in use - **🧭 Meta** (5) - Ask the server about its own tools and conventions -Not exposed (no tools): comments, imports, tag-name listings, the image-gallery -`data` endpoints, and `zip` export. +Not exposed (no tools): the image-gallery `data` endpoints. > 📖 See the complete [Tools Overview](docs/tools-overview.md) for detailed documentation diff --git a/docker-compose.yml b/docker-compose.yml index fcb8466..ef41640 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,7 +50,7 @@ services: start_period: 30s bookstack: - # Pinned, not :latest. The tool contract in this repo (59 tools, field shapes, + # Pinned, not :latest. The tool contract in this repo (71 tools, field shapes, # error codes) was verified against BookStack 26.05.2; a floating tag silently # re-points at a future release and turns a BookStack change into a mystery # failure in this suite. Currently resolves to v26.05.2-ls274. @@ -80,7 +80,7 @@ services: DB_USERNAME: bookstack DB_PASSWORD: bookstack_secret # BookStack throttles its REST API per user (default 180/min). The - # integration suites drive all 59 tools and share one admin token, so the + # integration suites drive the tools and share one admin token, so the # default becomes the bottleneck. This is a throwaway test instance, so # raise it. NOTE: real deployments keep the 180 default — that is why the # client retries on 429 and the test harness rides out throttling. @@ -105,7 +105,7 @@ services: BOOKSTACK_API_TOKEN: ${BOOKSTACK_API_TOKEN:-} # INBOUND authentication for POST /message - unrelated to the token above, # and required: the container refuses to start without it, because - # /message dispatches all 59 tools (permanent-delete, users, roles, + # /message dispatches all 71 tools (permanent-delete, users, roles, # permissions) using BOOKSTACK_API_TOKEN. Generate with `openssl rand -hex 32`. MCP_AUTH_TOKEN: ${MCP_AUTH_TOKEN:-} # Per-user OAuth instead of the two tokens above; see .env.example. diff --git a/docs/api-reference.md b/docs/api-reference.md index 41150e2..1196335 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -14,6 +14,9 @@ - [Roles](#roles-api) - [Attachments](#attachments-api) - [Images](#images-api) + - [Comments](#comments-api) + - [Imports](#imports-api) + - [Tags](#tags-api) - [Search](#search-api) - [Recycle Bin](#recycle-bin-api) - [Permissions](#permissions-api) @@ -29,13 +32,13 @@ The BookStack MCP Server provides comprehensive access to the BookStack knowledg ### Key Features -- **API Coverage**: 59 tools, 5 resources and 6 resource templates across 13 categories, covering the supported subset of the BookStack API (comments, imports, tag listings, image-gallery `data` endpoints and ZIP export are not exposed) +- **API Coverage**: 71 tools, 5 resources and 6 resource templates across 16 categories, covering the supported subset of the BookStack API (the image-gallery `data` endpoints are not exposed) - **Type Safety**: Full TypeScript interfaces for all operations - **Robust Error Handling**: Comprehensive error mapping and recovery guidance - **Rate Limiting**: Token bucket algorithm with configurable limits - **Validation**: Zod-based parameter validation, strict by default (`VALIDATION_STRICT_MODE=true`) - **Retry Logic**: Automatic retry with exponential backoff -- **Export Capabilities**: Multi-format export (HTML, PDF, Markdown, Plain Text) +- **Export Capabilities**: Multi-format export (HTML, PDF, Markdown, Plain Text, ZIP) ### Architecture @@ -275,13 +278,15 @@ interface BookWithContents extends Book { // Tool: bookstack_books_export interface ExportParams { id: number; - format: 'html' | 'pdf' | 'plaintext' | 'markdown'; + format: 'html' | 'pdf' | 'plaintext' | 'markdown' | 'zip'; } interface ExportResult { - content: string; // Base64 encoded for binary formats - filename: string; // Suggested filename - mime_type: string; // MIME type for the content + content: string; // The text, or base64 of the bytes for pdf and zip + encoding: 'utf8' | 'base64'; // base64 for pdf and zip + byte_length: number; // Size of the exported file in bytes + filename: string; // Suggested filename + mime_type: string; // e.g. application/zip for zip } ``` @@ -779,6 +784,135 @@ interface UpdateImageParams { // Permanently deletes image ``` +### Comments API + +Comments on pages. `local_id` numbers a comment within its page, and `parent_id` and +`reply_to` refer to that page-scoped number rather than the global `id`. + +#### List Comments +```typescript +// Tool: bookstack_comments_list +// Entries carry neither html nor archived; read a comment for those +interface CommentsListParams extends PaginationParams { + filter?: { + commentable_id?: number; // Page ID + commentable_type?: 'page'; + parent_id?: number; // local_id of the parent comment + local_id?: number; + content_ref?: string; + created_by?: number; + updated_by?: number; + }; +} +``` + +#### Create Comment +```typescript +// Tool: bookstack_comments_create +// Needs the comment-create-all permission +interface CreateCommentParams { + page_id: number; // Required + html: string; // Required, not blank + reply_to?: number; // local_id of the comment to reply to + content_ref?: string; // 'bkmrk-::-', max 255 chars +} +``` + +#### Read Comment +```typescript +// Tool: bookstack_comments_read +// Returns the comment with html, archived and its direct replies +``` + +#### Update Comment +```typescript +// Tool: bookstack_comments_update +interface UpdateCommentParams { + id: number; + html?: string; // Replaces the content + archived?: boolean; // Top-level comments only +} +``` + +#### Delete Comment +```typescript +// Tool: bookstack_comments_delete +// Permanently deletes the comment +``` + +### Imports API + +Imports of BookStack's portable ZIP format. All import tools need the `content-import` +permission. + +#### List Imports +```typescript +// Tool: bookstack_imports_list +interface ImportsListParams extends PaginationParams { + filter?: { + name?: string; + size?: number; // Bytes + type?: 'book' | 'chapter' | 'page'; + created_by?: number; + }; +} +``` + +#### Create Import +```typescript +// Tool: bookstack_imports_create +// Uploads and validates the ZIP as multipart; nothing is created until it is run +type CreateImportParams = + | { file: string } // Base64 encoded ZIP + | { file_path: string }; // Server-local path inside BOOKSTACK_UPLOAD_ROOT +``` + +#### Read Import +```typescript +// Tool: bookstack_imports_read +// Returns the pending import with `details` describing its content +``` + +#### Run Import +```typescript +// Tool: bookstack_imports_run +// Returns the created book, chapter or page, and deletes the import +interface RunImportParams { + id: number; + parent_type?: 'book' | 'chapter'; // Required with parent_id for chapter and page imports + parent_id?: number; +} +``` + +#### Delete Import +```typescript +// Tool: bookstack_imports_delete +// Permanently deletes a pending import without running it +``` + +### Tags API + +Read-only listings of the tags on content visible to the authenticated user. + +#### List Tag Names +```typescript +// Tool: bookstack_tags_list_names +interface TagNamesListParams extends PaginationParams { + filter?: { name?: string }; +} +// Entries: { name, values, usages, page_count, chapter_count, book_count, shelf_count } +``` + +#### List Tag Values +```typescript +// Tool: bookstack_tags_list_values +interface TagValuesListParams extends PaginationParams { + name: string; // Required tag name + filter?: { value?: string }; +} +// Entries: { name, value, usages, page_count, chapter_count, book_count, shelf_count } +``` + ### Search API Universal search across all content types. diff --git a/docs/examples-and-workflows.md b/docs/examples-and-workflows.md index 094bf2c..9c1c80f 100644 --- a/docs/examples-and-workflows.md +++ b/docs/examples-and-workflows.md @@ -61,7 +61,7 @@ curl http://localhost:3000/health "status": "healthy", "checks": [ { "name": "bookstack_connection", "healthy": true, "message": "BookStack API connection" }, - { "name": "tools_loaded", "healthy": true, "message": "59 tools loaded" }, + { "name": "tools_loaded", "healthy": true, "message": "71 tools loaded" }, { "name": "resources_loaded", "healthy": true, "message": "5 resources and 6 resource templates loaded" } ] } diff --git a/docs/integration-testing.md b/docs/integration-testing.md index 74d5d04..c606737 100644 --- a/docs/integration-testing.md +++ b/docs/integration-testing.md @@ -37,7 +37,7 @@ three services; `mcp` is not needed to run these tests. | `mcp` | built from `./Dockerfile` | Our MCP server. **Not required** by the integration suite. | The BookStack tag is **pinned, not `:latest`**. The whole tool contract in this repo — -59 tools, field shapes, error codes — was verified against v26.05.2, and a floating tag +71 tools, field shapes, error codes — was verified against v26.05.2, and a floating tag would silently re-point at a future release, turning an upstream change into a mystery failure here. Bump it deliberately in `docker-compose.yml`, then re-run the suite. diff --git a/docs/setup-guide.md b/docs/setup-guide.md index ceacaef..2593907 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -20,7 +20,7 @@ The BookStack MCP Server provides comprehensive access to BookStack's knowledge management capabilities through the Model Context Protocol (MCP). This guide covers everything you need to set up and configure the server for optimal performance. ### Key Features -- **59 MCP Tools** across 13 categories, covering the supported subset of the BookStack API +- **71 MCP Tools** across 16 categories, covering the supported subset of the BookStack API - **5 Resources and 6 Resource Templates** for dynamic content retrieval - **Rate Limiting** with configurable limits - **Comprehensive Validation** using Zod schemas @@ -54,6 +54,10 @@ free -h # Linux/macOS ### BookStack Version - **Recommended**: Latest stable version of BookStack - **API Version**: v1 (current) +- **Minimum versions for newer tools**: + - Tags (`bookstack_tags_*`): BookStack v26.05+ + - Comments (`bookstack_comments_*`): BookStack v25.11+ + - Imports (`bookstack_imports_*`) and the `zip` export format: BookStack v25.07+ > âš ī¸ **Note**: This server uses modern BookStack API features including the system endpoint for health checks. Please ensure you're running a recent version of BookStack. @@ -679,7 +683,7 @@ curl -i http://localhost:3000/health "status": "healthy", "checks": [ {"name": "bookstack_connection", "healthy": true, "message": "BookStack API connection"}, - {"name": "tools_loaded", "healthy": true, "message": "59 tools loaded"}, + {"name": "tools_loaded", "healthy": true, "message": "71 tools loaded"}, {"name": "resources_loaded", "healthy": true, "message": "5 resources and 6 resource templates loaded"} ] } diff --git a/docs/tools-overview.md b/docs/tools-overview.md index 3eb2637..15c353b 100644 --- a/docs/tools-overview.md +++ b/docs/tools-overview.md @@ -1,21 +1,21 @@ # BookStack MCP Server Tools Overview -## All 59 Tools Across 13 Categories +## All 71 Tools Across 16 Categories This document provides an overview of every tool implemented in the BookStack MCP server, its capabilities, usage patterns, and implementation details. ## Executive Summary -The BookStack MCP Server provides **59 tools** (plus **5 resources** and **6 resource templates**) organized into **13 categories**, implementing the supported subset of the BookStack knowledge management API. Each tool follows consistent patterns for validation, error handling, and logging. +The BookStack MCP Server provides **71 tools** (plus **5 resources** and **6 resource templates**) organized into **16 categories**, implementing the supported subset of the BookStack knowledge management API. Each tool follows consistent patterns for validation, error handling, and logging. The categories below are the ones returned by `bookstack_tool_categories`, and the -per-category counts add up to the 59 tools the server registers at boot (it logs -`Registered 59 tools` / `Registered 5 resources and 6 resource templates` on startup): +per-category counts add up to the 71 tools the server registers at boot (it logs +`Registered 71 tools` / `Registered 5 resources and 6 resource templates` on startup): | Section | Category | Tools | |---------|----------|-------| | 1 | `books` | 6 | -| 2 | `pages` | 6 | +| 2 | `pages` | 9 | | 3 | `chapters` | 6 | | 4 | `shelves` | 5 | | 5 | `users` | 5 | @@ -26,8 +26,11 @@ per-category counts add up to the 59 tools the server registers at boot (it logs | 10 | `recyclebin` | 3 | | 11 | `permissions` | 2 | | 12 | `system` | 2 | -| 13 | `meta` | 5 | -| | **Total** | **56** | +| 13 | `comments` | 5 | +| 14 | `imports` | 5 | +| 15 | `tags` | 2 | +| 16 | `meta` | 5 | +| | **Total** | **71** | ## Tool Categories Overview @@ -42,7 +45,7 @@ per-category counts add up to the 59 tools the server registers at boot (it logs | `bookstack_books_read` | Get complete book details including hierarchy | id (required) | | `bookstack_books_update` | Update book details and settings | id (required), name, description, tags, default_template_id | | `bookstack_books_delete` | Delete book (moves to recycle bin) | id (required) | -| `bookstack_books_export` | Export book in various formats | id (required), format (html/pdf/plaintext/markdown) | +| `bookstack_books_export` | Export book in various formats | id (required), format (html/pdf/plaintext/markdown/zip) | **Usage Patterns**: - Call `list` first to understand available documentation structure @@ -63,7 +66,7 @@ per-category counts add up to the 59 tools the server registers at boot (it logs | `bookstack_pages_append` | Add content at a page or section boundary | id (required), content (required), position, section, separator, dry_run, expected_updated_at | | `bookstack_pages_outline` | Heading structure with offsets and section sizes | id (required) | | `bookstack_pages_delete` | Delete page (moves to recycle bin) | id (required) | -| `bookstack_pages_export` | Export page in various formats | id (required), format (html/pdf/plaintext/markdown) | +| `bookstack_pages_export` | Export page in various formats | id (required), format (html/pdf/plaintext/markdown/zip) | **Content Support**: - HTML and Markdown formats @@ -118,7 +121,7 @@ No response from these tools contains page content, which is what keeps it out o | `bookstack_chapters_read` | Get chapter details including all pages | id (required) | | `bookstack_chapters_update` | Update chapter details and move between books | id (required), name, description, book_id, tags, priority | | `bookstack_chapters_delete` | Delete chapter and all pages | id (required) | -| `bookstack_chapters_export` | Export chapter with all pages | id (required), format (html/pdf/plaintext/markdown) | +| `bookstack_chapters_export` | Export chapter with all pages | id (required), format (html/pdf/plaintext/markdown/zip) | **Organizational Features**: - Priority-based ordering within books @@ -323,7 +326,57 @@ bookstack_audit_log_list({ filter: { loggable_type: "page", loggable_id: 42 } }) - Requires a token whose user can manage both users and system settings - Purging an item from the recycle bin nulls `loggable_id`/`loggable_type` on its entries and moves the item's name into `detail` — so purged content is traceable only by `type` + `detail` -### 13. Meta / Self-Description (5 tools) +### 13. Comment Management (5 tools) +**Category**: `comments` +**Purpose**: Discuss pages through comments and replies + +| Tool Name | Description | Key Parameters | +|-----------|-------------|----------------| +| `bookstack_comments_list` | List comments on visible pages | count, offset, sort, filter (commentable_id, commentable_type, parent_id, local_id, content_ref, created_by, updated_by) | +| `bookstack_comments_create` | Comment on a page or reply to a comment | page_id (required), html (required), reply_to, content_ref | +| `bookstack_comments_read` | Get a comment with its HTML and direct replies | id (required) | +| `bookstack_comments_update` | Edit, archive or unarchive a comment | id (required), html, archived | +| `bookstack_comments_delete` | Permanently delete a comment | id (required) | + +**Comment Features**: +- `local_id` numbers a comment within its page; `parent_id` and `reply_to` use it, not the global `id` +- Only top-level comments can be archived +- Listings omit `html` and `archived`; read a comment for both +- `content_ref` anchors a comment to page text and must match BookStack's `bkmrk-...` format + +### 14. ZIP Imports (5 tools) +**Category**: `imports` +**Purpose**: Import BookStack's portable ZIP exports + +| Tool Name | Description | Key Parameters | +|-----------|-------------|----------------| +| `bookstack_imports_list` | List pending imports | count, offset, sort, filter (name, size, type, created_by) | +| `bookstack_imports_create` | Upload and validate a ZIP without importing it | file (base64) or file_path, exactly one | +| `bookstack_imports_read` | Get a pending import with details of its content | id (required) | +| `bookstack_imports_run` | Create the content held in a pending import | id (required), parent_type + parent_id (together) | +| `bookstack_imports_delete` | Discard a pending import | id (required) | + +**Import Features**: +- Requires the BookStack `content-import` permission +- A ZIP from any `*_export` tool with `format: "zip"` can be imported +- Chapter imports need a book parent; page imports need a book or chapter parent; book imports take none +- `file_path` is read by this server and needs `BOOKSTACK_UPLOAD_ROOT`, like the other upload tools + +### 15. Tags (2 tools) +**Category**: `tags` +**Purpose**: Discover the tag vocabulary in use + +| Tool Name | Description | Key Parameters | +|-----------|-------------|----------------| +| `bookstack_tags_list_names` | List tag names with value and usage counts | count, offset, sort, filter (name) | +| `bookstack_tags_list_values` | List the values of one tag name with usage counts | name (required), count, offset, sort, filter (value) | + +**Tag Features**: +- Counts cover only content visible to the authenticated user +- Tags are set through the `tags` of books, chapters, pages and shelves; there is no tag write endpoint +- Feeds `[name=value]` filters in `bookstack_search` + +### 16. Meta / Self-Description (5 tools) **Category**: `meta` **Purpose**: Ask the server about itself @@ -331,7 +384,7 @@ bookstack_audit_log_list({ filter: { loggable_type: "page", loggable_id: 42 } }) |-----------|-------------|----------------| | `bookstack_server_info` | Get comprehensive MCP server information | section (all/capabilities/tools/resources/examples/errors) | | `bookstack_tool_categories` | Get detailed tool category information | category | -| `bookstack_usage_examples` | Get workflow examples | workflow (create_documentation/organize_content/user_management/search_content/export_data) | +| `bookstack_usage_examples` | Get workflow examples | workflow (create_documentation/edit_part_of_large_page/organize_content/user_management/search_content/export_data) | | `bookstack_error_guides` | Get error handling guidance | error_code (UNAUTHORIZED/NOT_FOUND/VALIDATION_ERROR) | | `bookstack_help` | Interactive help system | topic, context | @@ -480,6 +533,9 @@ Each tool category is implemented as a separate class: - **RoleTools**: Permission management - **AttachmentTools**: File attachment handling - **ImageTools**: Image gallery management +- **CommentTools**: Page comments +- **ImportTools**: ZIP imports +- **TagTools**: Tag listings - **SearchTools**: Content search - **RecycleBinTools**: Deletion recovery - **PermissionTools**: Access control @@ -537,7 +593,7 @@ Each tool follows the same pattern: - Filter results at the API level with a list tool's `filter`, rather than fetching everything and filtering locally - **There is no batch tool and no batching layer** (`supports_batch_operations: false`): every tool acts on a single item, so prefer one filtered list call over many individual reads - **The server caches nothing** (`supports_caching: false`): every call goes through to BookStack, so avoid polling loops — outbound requests are rate-limited -- Prefer `markdown`/`plaintext` exports for LLM context; `html` and `pdf` cost far more tokens +- Prefer `markdown`/`plaintext` exports for LLM context; `html`, `pdf` and `zip` cost far more tokens ### Error Handling - Always validate inputs before API calls @@ -568,8 +624,11 @@ below. It is not a complete mapping of every endpoint. - ✅ **User Management**: User and role administration - ✅ **Permission System**: Content-level permission overrides - ✅ **File Management**: Attachments and image gallery +- ✅ **Comments**: `/api/comments` (list, create, read, update, delete) +- ✅ **Imports**: `/api/imports` (list, create, read, run, delete) +- ✅ **Tags**: `/api/tags/names`, `/api/tags/values-for-name` - ✅ **Search**: `GET /api/search` -- ✅ **Export**: `html`, `pdf`, `plaintext`, `markdown` +- ✅ **Export**: `html`, `pdf`, `plaintext`, `markdown`, `zip` - ✅ **Audit**: `GET /api/audit-log` - ✅ **System**: `GET /api/system` - ✅ **Recycle bin**: list, restore, purge @@ -577,11 +636,7 @@ below. It is not a complete mapping of every endpoint. **Not exposed** (present in the BookStack API, no tool here — checked against v26.05.2 `docs.json`): -- ❌ **Comments** — `/api/comments` (list, create, read, update, delete) -- ❌ **Imports** — `/api/imports` (list, create, read, run, delete) -- ❌ **Tags** — `/api/tags/names`, `/api/tags/values-for-name` - ❌ **Image data** — `/api/image-gallery/{id}/data`, `/api/image-gallery/url/data` -- ❌ **ZIP export** — `/export/zip` on books, chapters and pages If you need one of these, call the BookStack API directly. @@ -595,6 +650,6 @@ The modular architecture allows for easy extension: ## Conclusion -The BookStack MCP Server is a production-ready implementation providing LLMs with access to the supported subset of BookStack's API. With 59 tools across 13 categories, 5 resources and 6 resource templates, robust error handling, strict validation, and extensive documentation, it enables sophisticated knowledge management workflows while maintaining security and reliability. +The BookStack MCP Server is a production-ready implementation providing LLMs with access to the supported subset of BookStack's API. With 71 tools across 16 categories, 5 resources and 6 resource templates, robust error handling, strict validation, and extensive documentation, it enables sophisticated knowledge management workflows while maintaining security and reliability. The consistent patterns, extensive examples, and self-documenting capabilities make it easy for LLMs to understand and effectively utilize the full power of the BookStack platform through the MCP protocol. diff --git a/src/api/client.ts b/src/api/client.ts index 0d6b0d1..19c7716 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,5 +1,6 @@ import { Buffer } from 'node:buffer'; -import { readFile, realpath } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import { open, realpath } from 'node:fs/promises'; import { Agent as HttpAgent } from 'node:http'; import { Agent as HttpsAgent } from 'node:https'; import { basename, extname, sep } from 'node:path'; @@ -16,6 +17,7 @@ import type { AttachmentsListParams, AuditLogEntry, AuditLogListParams, + BinaryExportFormat, Book, BookStackAPIClient, Bookshelf, @@ -25,12 +27,17 @@ import type { Chapter, ChaptersListParams, ChapterWithPages, + Comment, + CommentDetail, + CommentsListParams, ContentPermissions, ContentType, CreateAttachmentParams, CreateBookParams, CreateChapterParams, + CreateCommentParams, CreateImageParams, + CreateImportParams, CreatePageParams, CreateRoleParams, CreateShelfParams, @@ -40,6 +47,11 @@ import type { Image, ImageDetail, ImageGalleryListParams, + Import, + ImportCreateResult, + ImportDetail, + ImportRunResult, + ImportsListParams, ListResponse, Page, PagesListParams, @@ -52,13 +64,19 @@ import type { RoleListItem, RolesListParams, RoleWithPermissions, + RunImportParams, SearchParams, SearchResult, ShelvesListParams, SystemInfo, + TagNameSummary, + TagNamesListParams, + TagValueSummary, + TagValuesListParams, UpdateAttachmentParams, UpdateBookParams, UpdateChapterParams, + UpdateCommentParams, UpdateContentPermissionsParams, UpdateImageParams, UpdatePageParams, @@ -69,7 +87,8 @@ import type { UsersListParams, UserWithRoles, } from '../types'; -import type { ErrorHandler } from '../utils/errors'; +import { UPLOAD_MAX_BYTES } from '../types'; +import { type ErrorHandler, UploadRefusedError } from '../utils/errors'; import type { Logger } from '../utils/logger'; import { canonicalBaseUrl, @@ -115,7 +134,10 @@ const RETRY_JITTER_RATIO = 0.25; const REPLAY_SAFE_METHODS: readonly string[] = ['GET', 'HEAD', 'OPTIONS']; /** Export formats whose bytes are binary and must not survive as a decoded string. */ -const BINARY_EXPORT_FORMATS: readonly ExportFormat[] = ['pdf']; +const BINARY_EXPORT_FORMATS: readonly ExportFormat[] = [ + 'pdf', + 'zip', +] satisfies readonly BinaryExportFormat[]; /** * Content types BookStack labels its exports with. @@ -130,6 +152,7 @@ const EXPORT_MIME_TYPES: Record = { pdf: 'application/pdf', plaintext: 'text/plain', markdown: 'text/markdown', + zip: 'application/zip', }; /** Extension used when the response carries no usable filename. */ @@ -138,6 +161,7 @@ const EXPORT_EXTENSIONS: Record = { pdf: 'pdf', plaintext: 'txt', markdown: 'md', + zip: 'zip', }; /** Generic content types that carry no information and should not be reported as-is. */ @@ -338,10 +362,14 @@ export const UPLOAD_ROOT_UNUSABLE_MESSAGE = "'file_path' is unavailable because BOOKSTACK_UPLOAD_ROOT is not a readable directory on " + 'this server. Send the file content inline as base64 instead.'; -/** One refusal for missing, unreadable and out-of-root paths alike, so it reveals nothing. */ +/** One refusal for missing, unreadable, oversized, non-regular and out-of-root paths alike. */ export const UPLOAD_PATH_REFUSED_MESSAGE = - "'file_path' must name a readable file inside BOOKSTACK_UPLOAD_ROOT. Send the file content " + - 'inline as base64 instead.'; + "'file_path' must name a readable regular file of at most 50000 KB inside " + + 'BOOKSTACK_UPLOAD_ROOT. Send the file content inline as base64 instead.'; + +/** O_NONBLOCK so a FIFO cannot stall the open; O_NOFOLLOW so the vetted path cannot become a link. */ +const UPLOAD_OPEN_FLAGS = + fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0) | (fsConstants.O_NOFOLLOW ?? 0); /** * SECURITY GUARD for the `file_path` upload parameter. @@ -355,20 +383,22 @@ export const UPLOAD_PATH_REFUSED_MESSAGE = * in with `BOOKSTACK_UPLOAD_ROOT`. The candidate is then resolved with realpath() - which also * expands symlinks - and must be contained within the likewise-resolved root. This rejects * `../` traversal and symlink escapes, since containment is checked after resolution. + * The opened handle must then be a regular file of at most 50000 KB, so a directory, a FIFO + * or a device is refused rather than read. * - * Refusal is always explicit, and never names a resolved path or the root. + * Refusal is always an explicit UploadRefusedError, and never names a resolved path or the root. */ export async function readGuardedUploadFile(filePath: string): Promise { const uploadRoot = process.env.BOOKSTACK_UPLOAD_ROOT; if (!uploadRoot) { - throw new Error(UPLOAD_ROOT_UNSET_MESSAGE); + throw new UploadRefusedError(UPLOAD_ROOT_UNSET_MESSAGE); } let realRoot: string; try { realRoot = await realpath(uploadRoot); } catch { - throw new Error(UPLOAD_ROOT_UNUSABLE_MESSAGE); + throw new UploadRefusedError(UPLOAD_ROOT_UNUSABLE_MESSAGE); } try { @@ -376,9 +406,23 @@ export async function readGuardedUploadFile(filePath: string): Promise { if (!isContainedIn(realTarget, realRoot)) { throw new Error(UPLOAD_PATH_REFUSED_MESSAGE); } - return await readFile(realTarget); + // Stat the open handle, not the path, so the file checked is the file read. + const handle = await open(realTarget, UPLOAD_OPEN_FLAGS); + try { + const info = await handle.stat(); + if (!info.isFile() || info.size > UPLOAD_MAX_BYTES) { + throw new Error(UPLOAD_PATH_REFUSED_MESSAGE); + } + const bytes = await handle.readFile(); + if (bytes.byteLength > UPLOAD_MAX_BYTES) { + throw new Error(UPLOAD_PATH_REFUSED_MESSAGE); + } + return bytes; + } finally { + await handle.close(); + } } catch { - throw new Error(UPLOAD_PATH_REFUSED_MESSAGE); + throw new UploadRefusedError(UPLOAD_PATH_REFUSED_MESSAGE); } } @@ -776,8 +820,8 @@ export class BookStackClient implements BookStackAPIClient { /** * Upload via multipart/form-data. * - * BookStack's image-gallery and attachment endpoints take the payload as a file part - * and reject a JSON body, so these four routes bypass the JSON `request()` path. + * BookStack's image-gallery, attachment and import endpoints take the payload as a file + * part and reject a JSON body, so these routes bypass the JSON `request()` path. * * Two details make this work: * @@ -857,7 +901,7 @@ export class BookStackClient implements BookStackAPIClient { const inline = fields[fileField]; if (typeof inline === 'string' && inline.length > 0) { - throw new Error(`Provide either '${fileField}' or 'file_path', not both.`); + throw new UploadRefusedError(`Provide either '${fileField}' or 'file_path', not both.`); } const bytes = await readGuardedUploadFile(filePath); @@ -1264,6 +1308,104 @@ export class BookStackClient implements BookStackAPIClient { }); } + // Comments API + async listComments(params?: CommentsListParams): Promise> { + return this.request>({ + method: 'GET', + url: '/comments', + params, + }); + } + + async createComment(params: CreateCommentParams): Promise { + return this.request({ + method: 'POST', + url: '/comments', + data: params, + }); + } + + async getComment(id: number): Promise { + return this.request({ + method: 'GET', + url: `/comments/${id}`, + }); + } + + async updateComment(id: number, params: UpdateCommentParams): Promise { + return this.request({ + method: 'PUT', + url: `/comments/${id}`, + data: params, + }); + } + + async deleteComment(id: number): Promise { + await this.request({ + method: 'DELETE', + url: `/comments/${id}`, + }); + } + + // Imports API + async listImports(params?: ImportsListParams): Promise> { + return this.request>({ + method: 'GET', + url: '/imports', + params, + }); + } + + /** BookStack requires the ZIP as a multipart `file` part, so this route is always multipart. */ + async createImport(params: CreateImportParams): Promise { + const fields: UploadParams = { + file: params.file, + file_path: params.file_path, + }; + await this.resolveLocalFile(fields, 'file'); + + return this.uploadFile('POST', '/imports', fields, 'file'); + } + + async getImport(id: number): Promise { + return this.request({ + method: 'GET', + url: `/imports/${id}`, + }); + } + + async runImport(id: number, params: RunImportParams): Promise { + return this.request({ + method: 'POST', + url: `/imports/${id}`, + data: params, + }); + } + + async deleteImport(id: number): Promise { + await this.request({ + method: 'DELETE', + url: `/imports/${id}`, + }); + } + + // Tags API + async listTagNames(params?: TagNamesListParams): Promise> { + return this.request>({ + method: 'GET', + url: '/tags/names', + params, + }); + } + + async listTagValues(params: TagValuesListParams): Promise> { + return this.request>({ + method: 'GET', + url: '/tags/values-for-name', + params, + }); + } + // Search API async search(params: SearchParams): Promise> { return this.request>({ diff --git a/src/config/manager.ts b/src/config/manager.ts index 5c2bbb2..487eab0 100644 --- a/src/config/manager.ts +++ b/src/config/manager.ts @@ -198,7 +198,7 @@ const AuthModeSchema = z * Settings that exist only for the HTTP transport. * * Deliberately kept out of `ConfigSchema`: `Config` is handed to the BookStack client, - * the validator and all 59 tools, and is merged per request in the /message handler + * the validator and all 71 tools, and is merged per request in the /message handler * (`Partial` overrides). The inbound secret has no business travelling with it, * and the body ceiling means nothing under stdio. */ diff --git a/src/server.ts b/src/server.ts index debb9a9..98e64fb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -36,7 +36,9 @@ import { AttachmentTools } from './tools/attachments'; import { AuditTools } from './tools/audit'; import { BookTools } from './tools/books'; import { ChapterTools } from './tools/chapters'; +import { CommentTools } from './tools/comments'; import { ImageTools } from './tools/images'; +import { ImportTools } from './tools/imports'; import { PageTools } from './tools/pages'; import { PermissionTools } from './tools/permissions'; import { RecycleBinTools } from './tools/recyclebin'; @@ -45,6 +47,7 @@ import { SearchTools } from './tools/search'; import { ServerInfoTools } from './tools/server-info'; import { ShelfTools } from './tools/shelves'; import { SystemTools } from './tools/system'; +import { TagTools } from './tools/tags'; import { UserTools } from './tools/users'; import type { MCPResource, MCPSchemaNode, MCPTool } from './types'; import { ErrorHandler } from './utils/errors'; @@ -125,10 +128,10 @@ function splitArgumentNames(tool: MCPTool, args: unknown): { known: string[]; un * through the Model Context Protocol (MCP). * * Features: - * - 59 tools across the supported subset of the BookStack API: books, chapters, pages, - * shelves, users, roles, attachments, image gallery, search, recycle bin, content - * permissions, the audit log and system info. Not every endpoint family is exposed - - * comments, imports, tags, image-gallery `data` and ZIP export are not. + * - 71 tools across the supported subset of the BookStack API: books, chapters, pages, + * shelves, users, roles, attachments, image gallery, comments, ZIP imports, tags, search, + * recycle bin, content permissions, the audit log and system info, with html, pdf, + * plaintext, markdown and ZIP exports. Image-gallery `data` is not exposed. * - 5 resources and 6 resource templates for read-only content access * - Comprehensive error handling and validation * - Rate limiting and retry policies @@ -232,6 +235,9 @@ export class BookStackMCPServer { new RoleTools(this.client, this.validator, this.logger), new AttachmentTools(this.client, this.validator, this.logger), new ImageTools(this.client, this.validator, this.logger), + new CommentTools(this.client, this.validator, this.logger), + new ImportTools(this.client, this.validator, this.logger), + new TagTools(this.client, this.validator, this.logger), new SearchTools(this.client, this.validator, this.logger), new RecycleBinTools(this.client, this.validator, this.logger), new PermissionTools(this.client, this.validator, this.logger), @@ -563,7 +569,7 @@ export class BookStackMCPServer { */ export const MISSING_AUTH_TOKEN_MESSAGE = 'MCP_AUTH_TOKEN is not set. The HTTP transport refuses to start without an inbound ' + - 'secret, because POST /message dispatches all 59 tools - including permanent-delete, ' + + 'secret, because POST /message dispatches all 71 tools - including permanent-delete, ' + 'user, role and permission operations - using the configured BOOKSTACK_API_TOKEN. ' + 'Set MCP_AUTH_TOKEN to a random secret (e.g. `openssl rand -hex 32`) and send it as ' + '"Authorization: Bearer ", or use MCP_TRANSPORT=stdio, which has no network ' + @@ -996,7 +1002,7 @@ export function createHttpApp(options: HttpAppOptions): express.Express { * * Built from the app's own config rather than re-reading the singleton, so the readiness * probe reports on the BookStack this app was actually configured with. Cached across - * requests because constructing one registers all 59 tools, 5 resources and 6 resource + * requests because constructing one registers all 71 tools, 5 resources and 6 resource * templates - work an anonymous caller must not be able to trigger per request. */ function healthServer(): BookStackMCPServer { diff --git a/src/tools/attachments.ts b/src/tools/attachments.ts index 8b7fc1e..be0732e 100644 --- a/src/tools/attachments.ts +++ b/src/tools/attachments.ts @@ -3,6 +3,7 @@ import { type AttachmentsListParams, type CreateAttachmentParams, type MCPTool, + UPLOAD_MAX_BASE64_LENGTH, type UpdateAttachmentParams, withClosedSchemas, } from '../types'; @@ -174,6 +175,7 @@ export class AttachmentTools { file: { type: 'string', minLength: 1, + maxLength: UPLOAD_MAX_BASE64_LENGTH, description: 'Base64 encoded file content, at most 50000 KB. Mutually exclusive with file_path and link.', }, @@ -353,6 +355,7 @@ export class AttachmentTools { file: { type: 'string', minLength: 1, + maxLength: UPLOAD_MAX_BASE64_LENGTH, description: 'New Base64 encoded file content (Replaces existing file), at most 50000 KB. Mutually exclusive with file_path and link.', }, diff --git a/src/tools/books.ts b/src/tools/books.ts index 60a3a84..c15a0cc 100644 --- a/src/tools/books.ts +++ b/src/tools/books.ts @@ -434,7 +434,7 @@ export class BookTools { return { name: 'bookstack_books_export', description: - 'Export a book to a specific format. Useful for backups, offline reading, or migrating content. Returns { content, encoding, byte_length, filename, mime_type }: text formats arrive as-is with encoding "utf8", while "pdf" arrives base64-encoded with encoding "base64".', + 'Export a book to a specific format. Useful for backups, offline reading, or migrating content. Returns { content, encoding, byte_length, filename, mime_type }: text formats arrive as-is with encoding "utf8", while "pdf" and "zip" arrive base64-encoded with encoding "base64". "zip" is the portable format bookstack_imports_create accepts, and requires BookStack v25.07+.', inputSchema: { type: 'object', required: ['id', 'format'], @@ -446,8 +446,8 @@ export class BookTools { }, format: { type: 'string', - enum: ['html', 'pdf', 'plaintext', 'markdown'], - description: 'The desired export format.', + enum: ['html', 'pdf', 'plaintext', 'markdown', 'zip'], + description: 'Desired export format. "zip" requires BookStack v25.07+.', }, }, }, @@ -461,9 +461,13 @@ export class BookTools { ], usage_patterns: [ 'Use "markdown" or "plaintext" for LLM context injection as they are more token-efficient than HTML or PDF', - 'Check `encoding` before using `content`: for "pdf" it is base64 and must be decoded to bytes, not read as text. Use `byte_length` for the real file size - `content.length` counts characters, not bytes.', + 'Check `encoding` before using `content`: for "pdf" and "zip" it is base64 and must be decoded to bytes, not read as text. Use `byte_length` for the real file size - `content.length` counts characters, not bytes.', + ], + related_tools: [ + 'bookstack_pages_export', + 'bookstack_chapters_export', + 'bookstack_imports_create', ], - related_tools: ['bookstack_pages_export', 'bookstack_chapters_export'], error_codes: [ { code: 'NOT_FOUND', diff --git a/src/tools/chapters.ts b/src/tools/chapters.ts index b3eeb85..74e1779 100644 --- a/src/tools/chapters.ts +++ b/src/tools/chapters.ts @@ -449,7 +449,7 @@ export class ChapterTools { return { name: 'bookstack_chapters_export', description: - 'Export a chapter to a specific format. Includes content from all pages in the chapter. Returns { content, encoding, byte_length, filename, mime_type }: text formats arrive as-is with encoding "utf8", while "pdf" arrives base64-encoded with encoding "base64".', + 'Export a chapter to a specific format. Includes content from all pages in the chapter. Returns { content, encoding, byte_length, filename, mime_type }: text formats arrive as-is with encoding "utf8", while "pdf" and "zip" arrive base64-encoded with encoding "base64". "zip" is the portable format bookstack_imports_create accepts, and requires BookStack v25.07+.', inputSchema: { type: 'object', required: ['id', 'format'], @@ -461,8 +461,8 @@ export class ChapterTools { }, format: { type: 'string', - enum: ['html', 'pdf', 'plaintext', 'markdown'], - description: 'Desired format', + enum: ['html', 'pdf', 'plaintext', 'markdown', 'zip'], + description: 'Desired export format. "zip" requires BookStack v25.07+.', }, }, }, @@ -476,9 +476,9 @@ export class ChapterTools { ], usage_patterns: [ 'Use "plaintext" or "markdown" for LLM context injection', - 'Check `encoding` before using `content`: for "pdf" it is base64 and must be decoded to bytes, not read as text. Use `byte_length` for the real file size - `content.length` counts characters, not bytes.', + 'Check `encoding` before using `content`: for "pdf" and "zip" it is base64 and must be decoded to bytes, not read as text. Use `byte_length` for the real file size - `content.length` counts characters, not bytes.', ], - related_tools: ['bookstack_books_export'], + related_tools: ['bookstack_books_export', 'bookstack_imports_create'], error_codes: [ { code: 'NOT_FOUND', diff --git a/src/tools/comments.ts b/src/tools/comments.ts new file mode 100644 index 0000000..8c09f4c --- /dev/null +++ b/src/tools/comments.ts @@ -0,0 +1,402 @@ +import type { BookStackClient } from '../api/client'; +import { + COMMENT_CONTENT_REF_PATTERN, + type CommentsListParams, + type CreateCommentParams, + type MCPTool, + NONBLANK_PATTERN, + type UpdateCommentParams, + withClosedSchemas, +} from '../types'; +import type { Logger } from '../utils/logger'; +import type { IdRequest, ValidationHandler } from '../validation/validator'; + +/** The BookStack release that added the comments API. */ +const COMMENTS_VERSION_NOTE = 'Requires BookStack v25.11+.'; + +/** The whole `bookstack_comments_update` request: which comment, plus the changes. */ +type UpdateCommentRequest = UpdateCommentParams & IdRequest; + +/** Page comment tools: list, create, read, update and delete. */ +export class CommentTools { + constructor( + private client: BookStackClient, + private validator: ValidationHandler, + private logger: Logger + ) {} + + /** Get all comment tools */ + getTools(): MCPTool[] { + return withClosedSchemas([ + this.createListCommentsTool(), + this.createCreateCommentTool(), + this.createReadCommentTool(), + this.createUpdateCommentTool(), + this.createDeleteCommentTool(), + ]); + } + + /** List comments tool */ + private createListCommentsTool(): MCPTool { + return { + name: 'bookstack_comments_list', + description: `List the comments on pages the authenticated user can see, with pagination, sorting and exact-match filters. Entries carry no \`html\` and no \`archived\` flag - read a comment for those. \`parent_id\` and \`local_id\` are numbers scoped to the page, not global comment IDs. ${COMMENTS_VERSION_NOTE}`, + category: 'comments', + inputSchema: { + type: 'object', + properties: { + count: { + type: 'integer', + minimum: 1, + maximum: 500, + default: 20, + description: 'Number of comments to return', + }, + offset: { + type: 'integer', + minimum: 0, + default: 0, + description: 'Number of comments to skip', + }, + sort: { + type: 'string', + enum: [ + 'id', + 'commentable_id', + 'commentable_type', + 'parent_id', + 'local_id', + 'content_ref', + 'created_by', + 'updated_by', + 'created_at', + 'updated_at', + '-id', + '-commentable_id', + '-commentable_type', + '-parent_id', + '-local_id', + '-content_ref', + '-created_by', + '-updated_by', + '-created_at', + '-updated_at', + ], + default: 'id', + description: 'Sort field. Prefix with "-" to sort descending.', + }, + filter: { + type: 'object', + properties: { + commentable_id: { + type: 'integer', + minimum: 1, + description: 'Filter by the ID of the page the comment is on', + }, + commentable_type: { + type: 'string', + enum: ['page'], + description: 'Filter by the type of item commented on. Only pages take comments.', + }, + parent_id: { + type: 'integer', + minimum: 1, + description: + "Filter to replies to one comment, given as that comment's local_id. Combine with commentable_id, since local_id is only unique within a page.", + }, + local_id: { + type: 'integer', + minimum: 1, + description: + "Filter by a comment's page-scoped number. Combine with commentable_id.", + }, + content_ref: { + type: 'string', + description: 'Filter by the page text reference the comment is anchored to', + }, + created_by: { + type: 'integer', + minimum: 1, + description: 'Filter by the ID of the user who wrote the comment', + }, + updated_by: { + type: 'integer', + minimum: 1, + description: 'Filter by the ID of the user who last updated the comment', + }, + }, + description: 'Optional filters to apply. All filters match exactly.', + }, + }, + }, + examples: [ + { + description: 'List the comments on one page', + input: { filter: { commentable_id: 42 } }, + expected_output: 'Array of comment objects without their html', + use_case: 'Reviewing the discussion on a page', + }, + { + description: 'List the replies to the first comment on a page', + input: { filter: { commentable_id: 42, parent_id: 1 } }, + expected_output: 'The direct replies to comment #1 on page 42', + use_case: 'Following one thread', + }, + ], + usage_patterns: [ + 'Filter by commentable_id to see the comments on one page', + 'Use bookstack_comments_read for the comment text and its direct replies', + ], + related_tools: ['bookstack_comments_read', 'bookstack_pages_read'], + error_codes: [ + { + code: 'UNAUTHORIZED', + description: 'Authentication failed or insufficient permissions', + recovery_suggestion: 'Verify API token and permissions', + }, + ], + handler: async (params: unknown) => { + const validatedParams = this.validator.validateParams( + params, + 'commentsList' + ); + // Filter KEYS only, after validation. See the same line in src/tools/books.ts. + this.logger.debug('Listing comments', { + count: validatedParams.count, + offset: validatedParams.offset, + sort: validatedParams.sort, + filters: Object.keys(validatedParams.filter ?? {}), + }); + return await this.client.listComments(validatedParams); + }, + }; + } + + /** Create comment tool */ + private createCreateCommentTool(): MCPTool { + return { + name: 'bookstack_comments_create', + description: `Add a comment to a page, or reply to an existing comment on it. Requires the BookStack "comment-create-all" permission and view access to the page; draft pages cannot be commented on. BookStack keeps only simple HTML in a comment (p, a, ol, ul, li, strong, em, span, br, code) and strips the rest. ${COMMENTS_VERSION_NOTE}`, + inputSchema: { + type: 'object', + required: ['page_id', 'html'], + properties: { + page_id: { + type: 'integer', + minimum: 1, + description: 'ID of the page to comment on', + }, + html: { + type: 'string', + minLength: 1, + pattern: NONBLANK_PATTERN, + description: 'The comment content as HTML, e.g. "

Looks good

"', + }, + reply_to: { + type: 'integer', + minimum: 1, + description: + 'The local_id of the comment to reply to, on the same page - NOT its global id. A local_id that does not exist on the page is silently ignored and the comment is posted at top level.', + }, + content_ref: { + type: 'string', + maxLength: 255, + pattern: COMMENT_CONTENT_REF_PATTERN, + description: + "Anchor the comment to page text, as 'bkmrk-::-' (e.g. 'bkmrk-page-title:7341676876991010:3-14'). BookStack stores any other value as '', so a malformed one is refused here.", + }, + }, + }, + examples: [ + { + description: 'Comment on a page', + input: { page_id: 42, html: '

Can the title be updated?

' }, + expected_output: 'Comment object with its id and local_id', + use_case: 'Leaving review feedback', + }, + { + description: 'Reply to the first comment on a page', + input: { page_id: 42, html: '

Done.

', reply_to: 1 }, + expected_output: 'Comment object whose parent_id is 1', + use_case: 'Answering a question in its thread', + }, + ], + usage_patterns: [ + 'Take reply_to from the local_id of the comment being answered, never its id', + ], + related_tools: ['bookstack_comments_list', 'bookstack_comments_read'], + error_codes: [ + { + code: 'NOT_FOUND', + description: 'Page not found or not visible', + recovery_suggestion: 'Verify page_id', + }, + { + code: 'UNAUTHORIZED', + description: 'Missing the comment-create-all permission', + recovery_suggestion: 'Ask an administrator to grant comment creation to your role', + }, + ], + handler: async (params: unknown) => { + const validatedParams = this.validator.validateParams( + params, + 'commentCreate' + ); + // The body's size, not the body. See the same line in src/tools/books.ts. + this.logger.info('Creating comment', { + page_id: validatedParams.page_id, + reply_to: validatedParams.reply_to, + html_length: validatedParams.html.length, + }); + return await this.client.createComment(validatedParams); + }, + }; + } + + /** Read comment tool */ + private createReadCommentTool(): MCPTool { + return { + name: 'bookstack_comments_read', + description: `Get one comment with its HTML content, archived status and direct replies. Visible when the page it is on is visible to the authenticated user. ${COMMENTS_VERSION_NOTE}`, + inputSchema: { + type: 'object', + required: ['id'], + properties: { + id: { + type: 'integer', + minimum: 1, + description: 'The global ID of the comment (not its local_id).', + }, + }, + }, + examples: [ + { + description: 'Read a comment and its replies', + input: { id: 22 }, + expected_output: 'Comment object with html, archived and replies', + use_case: 'Reading a discussion thread', + }, + ], + usage_patterns: ['Replies carry their own html, so one read covers a thread one level deep'], + related_tools: ['bookstack_comments_list'], + error_codes: [ + { + code: 'NOT_FOUND', + description: 'Comment not found or not visible', + recovery_suggestion: 'Verify ID', + }, + ], + handler: async (params: unknown) => { + const { id } = this.validator.validateParams(params, 'id'); + this.logger.debug('Reading comment', { id }); + return await this.client.getComment(id); + }, + }; + } + + /** Update comment tool */ + private createUpdateCommentTool(): MCPTool { + return { + name: 'bookstack_comments_update', + description: `Change the content of a comment, or archive or unarchive it: send html, archived, or both. Requires the BookStack "comment-update-all" permission, or "comment-update-own" for your own comments. Only top-level comments can be archived: BookStack answers 400 for a reply. ${COMMENTS_VERSION_NOTE}`, + inputSchema: { + type: 'object', + required: ['id'], + properties: { + id: { + type: 'integer', + minimum: 1, + description: 'The global ID of the comment to update', + }, + html: { + type: 'string', + minLength: 1, + pattern: NONBLANK_PATTERN, + description: 'New comment content as HTML. Replaces the existing content.', + }, + archived: { + type: 'boolean', + description: + 'true archives the comment, false unarchives it. Send only to change the archive state.', + }, + }, + anyOf: [{ required: ['html'] }, { required: ['archived'] }], + }, + examples: [ + { + description: 'Archive a resolved comment', + input: { id: 167, archived: true }, + expected_output: 'Updated comment object with archived: true', + use_case: 'Marking feedback as dealt with', + }, + ], + usage_patterns: ['Read the comment first to check parent_id is null before archiving'], + related_tools: ['bookstack_comments_read'], + error_codes: [ + { + code: 'NOT_FOUND', + description: 'Comment not found or not visible', + recovery_suggestion: 'Verify ID', + }, + { + code: 'UNAUTHORIZED', + description: 'Missing comment-update-all, or comment-update-own on your own comment', + recovery_suggestion: 'Ask an administrator for the comment update permission', + }, + ], + handler: async (params: unknown) => { + // Validate first, destructure second: see src/tools/attachments.ts. + const { id, ...updateParams } = this.validator.validateParams( + params, + 'commentUpdate' + ); + this.logger.info('Updating comment', { id, fields: Object.keys(updateParams) }); + return await this.client.updateComment(id, updateParams); + }, + }; + } + + /** Delete comment tool */ + private createDeleteCommentTool(): MCPTool { + return { + name: 'bookstack_comments_delete', + description: `Permanently delete a comment. Requires the BookStack "comment-delete-all" permission, or "comment-delete-own" for your own comments. Comments do not go to the recycle bin. ${COMMENTS_VERSION_NOTE}`, + inputSchema: { + type: 'object', + required: ['id'], + properties: { + id: { + type: 'integer', + minimum: 1, + description: 'The global ID of the comment to delete', + }, + }, + }, + examples: [ + { + description: 'Delete a comment', + input: { id: 167 }, + expected_output: 'Success message', + use_case: 'Removing an obsolete comment', + }, + ], + usage_patterns: ['Consider archiving a top-level comment instead, which keeps it readable'], + related_tools: ['bookstack_comments_update'], + error_codes: [ + { + code: 'NOT_FOUND', + description: 'Comment not found or not visible', + recovery_suggestion: 'Verify ID', + }, + ], + handler: async (params: unknown) => { + const { id } = this.validator.validateParams(params, 'id'); + this.logger.warn('Deleting comment', { id }); + await this.client.deleteComment(id); + return { success: true, message: `Comment ${id} deleted successfully` }; + }, + }; + } +} + +export default CommentTools; diff --git a/src/tools/images.ts b/src/tools/images.ts index 37e1467..1a0419e 100644 --- a/src/tools/images.ts +++ b/src/tools/images.ts @@ -3,6 +3,7 @@ import { type CreateImageParams, type ImageGalleryListParams, type MCPTool, + UPLOAD_MAX_BASE64_LENGTH, type UpdateImageParams, withClosedSchemas, } from '../types'; @@ -164,6 +165,7 @@ export class ImageTools { image: { type: 'string', minLength: 1, + maxLength: UPLOAD_MAX_BASE64_LENGTH, description: 'Base64 encoded image content. Supply this or file_path, not both. Must be a JPEG, PNG, GIF, WebP or AVIF, and at most 50000 KB.', }, @@ -344,6 +346,7 @@ export class ImageTools { image: { type: 'string', minLength: 1, + maxLength: UPLOAD_MAX_BASE64_LENGTH, description: 'New Base64 encoded image content (Replaces existing image). Supply this or file_path, not both. Should be the same file type as the original image, and at most 50000 KB.', }, diff --git a/src/tools/imports.ts b/src/tools/imports.ts new file mode 100644 index 0000000..11822de --- /dev/null +++ b/src/tools/imports.ts @@ -0,0 +1,381 @@ +import type { BookStackClient } from '../api/client'; +import { + type CreateImportParams, + type ImportsListParams, + type MCPTool, + type RunImportParams, + UPLOAD_MAX_BASE64_LENGTH, + withClosedSchemas, +} from '../types'; +import type { Logger } from '../utils/logger'; +import type { IdRequest, ValidationHandler } from '../validation/validator'; + +/** The whole `bookstack_imports_run` request: which import, plus where it goes. */ +type RunImportRequest = RunImportParams & IdRequest; + +/** The version and permission note every import tool carries. */ +const IMPORT_PERMISSION_NOTE = + 'Requires BookStack v25.07+ and the "content-import" permission; without "settings-manage" only your own imports are visible.'; + +/** ZIP import tools: list, upload, read, run and delete pending imports. */ +export class ImportTools { + constructor( + private client: BookStackClient, + private validator: ValidationHandler, + private logger: Logger + ) {} + + /** Get all import tools */ + getTools(): MCPTool[] { + return withClosedSchemas([ + this.createListImportsTool(), + this.createCreateImportTool(), + this.createReadImportTool(), + this.createRunImportTool(), + this.createDeleteImportTool(), + ]); + } + + /** List imports tool */ + private createListImportsTool(): MCPTool { + return { + name: 'bookstack_imports_list', + description: `List pending ZIP imports - uploaded but not yet run. ${IMPORT_PERMISSION_NOTE}`, + category: 'imports', + inputSchema: { + type: 'object', + properties: { + count: { + type: 'integer', + minimum: 1, + maximum: 500, + default: 20, + description: 'Number of imports to return', + }, + offset: { + type: 'integer', + minimum: 0, + default: 0, + description: 'Number of imports to skip', + }, + sort: { + type: 'string', + enum: [ + 'id', + 'name', + 'size', + 'type', + 'created_by', + 'created_at', + 'updated_at', + '-id', + '-name', + '-size', + '-type', + '-created_by', + '-created_at', + '-updated_at', + ], + default: 'id', + description: 'Sort field. Prefix with "-" to sort descending.', + }, + filter: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Filter by the name of the top-level item in the ZIP', + }, + size: { + type: 'integer', + minimum: 0, + description: 'Filter by ZIP size in bytes', + }, + type: { + type: 'string', + enum: ['book', 'chapter', 'page'], + description: 'Filter by what the import will create', + }, + created_by: { + type: 'integer', + minimum: 1, + description: 'Filter by the ID of the user who uploaded the ZIP', + }, + }, + description: 'Optional filters to apply. All filters match exactly.', + }, + }, + }, + examples: [ + { + description: 'List pending book imports', + input: { filter: { type: 'book' } }, + expected_output: 'Array of import objects', + use_case: 'Finding an uploaded ZIP to run', + }, + ], + usage_patterns: ['A run import is removed from this list, so it holds only pending work'], + related_tools: ['bookstack_imports_read', 'bookstack_imports_run'], + error_codes: [ + { + code: 'UNAUTHORIZED', + description: 'Missing the content-import permission', + recovery_suggestion: 'Ask an administrator to grant content import to your role', + }, + ], + handler: async (params: unknown) => { + const validatedParams = this.validator.validateParams( + params, + 'importsList' + ); + // Filter KEYS only, after validation. See the same line in src/tools/books.ts. + this.logger.debug('Listing imports', { + count: validatedParams.count, + offset: validatedParams.offset, + sort: validatedParams.sort, + filters: Object.keys(validatedParams.filter ?? {}), + }); + return await this.client.listImports(validatedParams); + }, + }; + } + + /** Create import tool */ + private createCreateImportTool(): MCPTool { + return { + name: 'bookstack_imports_create', + description: `Upload a BookStack ZIP export so it can be imported. BookStack validates and stores the ZIP but creates no content until bookstack_imports_run is called. Provide EXACTLY ONE of file or file_path. ${IMPORT_PERMISSION_NOTE}`, + inputSchema: { + type: 'object', + properties: { + file: { + type: 'string', + minLength: 1, + maxLength: UPLOAD_MAX_BASE64_LENGTH, + description: + 'Base64 encoded ZIP content, at most 50000 KB. Mutually exclusive with file_path.', + }, + file_path: { + type: 'string', + minLength: 1, + description: + 'Path to a ZIP on the server to upload instead of inlining base64. Mutually exclusive with file. Requires the operator to set BOOKSTACK_UPLOAD_ROOT, under every transport, and the path must resolve inside it.', + }, + }, + oneOf: [ + { + title: 'Upload inline base64 content', + required: ['file'], + not: { required: ['file_path'] }, + }, + { + title: 'Upload a file already on the server', + required: ['file_path'], + not: { required: ['file'] }, + }, + ], + }, + examples: [ + { + description: 'Upload a ZIP from the server', + input: { file_path: '/srv/uploads/handbook.zip' }, + expected_output: 'Import object with its id, name, type and size', + use_case: 'Staging a book exported from another BookStack instance', + }, + ], + usage_patterns: [ + 'The ZIP must be in BookStack\'s own export format, e.g. from bookstack_books_export with format "zip"', + 'Check `type` on the result: a chapter or page import needs a parent when it is run', + ], + related_tools: ['bookstack_imports_run', 'bookstack_books_export'], + error_codes: [ + { + code: 'VALIDATION_ERROR', + description: + 'Neither or both of file and file_path supplied, or BookStack rejected the ZIP contents (its 422 lists the problems)', + recovery_suggestion: 'Send exactly one source, holding a BookStack ZIP export', + }, + { + code: 'VALIDATION_ERROR', + description: 'file_path refused, or resolves outside BOOKSTACK_UPLOAD_ROOT', + recovery_suggestion: + 'Send the content as base64, or ask the operator to set BOOKSTACK_UPLOAD_ROOT to a directory holding the file', + }, + ], + handler: async (params: unknown) => { + const validatedParams = this.validator.validateParams( + params, + 'importCreate' + ); + this.logger.info('Creating import', { + source: validatedParams.file_path ? 'file_path' : 'base64', + }); + return await this.client.createImport(validatedParams); + }, + }; + } + + /** Read import tool */ + private createReadImportTool(): MCPTool { + return { + name: 'bookstack_imports_read', + description: `Get a pending ZIP import, with \`details\` describing the content it holds - its shape depends on \`type\`. ${IMPORT_PERMISSION_NOTE}`, + inputSchema: { + type: 'object', + required: ['id'], + properties: { + id: { + type: 'integer', + minimum: 1, + description: 'The ID of the import', + }, + }, + }, + examples: [ + { + description: 'Inspect an import before running it', + input: { id: 31 }, + expected_output: 'Import object with details of its chapters, pages and tags', + use_case: 'Checking what a ZIP will create', + }, + ], + usage_patterns: ['Read before running to confirm the type and contents'], + related_tools: ['bookstack_imports_run'], + error_codes: [ + { + code: 'NOT_FOUND', + description: 'Import not found, already run, or not yours', + recovery_suggestion: 'List imports to find a pending one', + }, + ], + handler: async (params: unknown) => { + const { id } = this.validator.validateParams(params, 'id'); + this.logger.debug('Reading import', { id }); + return await this.client.getImport(id); + }, + }; + } + + /** Run import tool */ + private createRunImportTool(): MCPTool { + return { + name: 'bookstack_imports_run', + description: `WRITE ACTION: run a pending ZIP import, creating its book, chapter or page - with any images and attachments it holds - in BookStack. On success the new item is returned and the import is deleted. A chapter import needs parent_type "book"; a page import needs a book or chapter parent; a book import takes no parent and ignores one. ${IMPORT_PERMISSION_NOTE} Creating the content also needs the matching create permissions.`, + inputSchema: { + type: 'object', + required: ['id'], + properties: { + id: { + type: 'integer', + minimum: 1, + description: 'The ID of the import to run', + }, + parent_type: { + type: 'string', + enum: ['book', 'chapter'], + description: 'Type of the parent to import into. Send together with parent_id.', + }, + parent_id: { + type: 'integer', + minimum: 1, + description: 'ID of the parent to import into. Send together with parent_type.', + }, + }, + // Both parent fields or neither. + anyOf: [ + { required: ['parent_type', 'parent_id'] }, + { not: { anyOf: [{ required: ['parent_type'] }, { required: ['parent_id'] }] } }, + ], + }, + examples: [ + { + description: 'Import a chapter ZIP into a book', + input: { id: 31, parent_type: 'book', parent_id: 28 }, + expected_output: 'The created chapter', + use_case: 'Restoring a chapter into an existing book', + }, + { + description: 'Import a book ZIP', + input: { id: 25 }, + expected_output: 'The created book', + use_case: 'Migrating a book between instances', + }, + ], + usage_patterns: [ + 'Read the import first: its type decides whether a parent is required', + 'Not idempotent - each successful run creates new content', + ], + related_tools: ['bookstack_imports_read', 'bookstack_imports_create'], + error_codes: [ + { + code: 'VALIDATION_ERROR', + description: + 'Only one of parent_type and parent_id supplied, or a parent is missing for a chapter or page import', + recovery_suggestion: 'Send both parent fields for a chapter or page import', + }, + { + code: 'INTERNAL_ERROR', + description: + 'BookStack could not import the ZIP, e.g. a chapter given a chapter parent or missing permissions; its 500 lists the problems and nothing is created', + recovery_suggestion: 'Fix the listed problems and run the import again', + }, + ], + handler: async (params: unknown) => { + const { id, ...runParams } = this.validator.validateParams( + params, + 'importRun' + ); + this.logger.warn('Running import', { + id, + parent_id: runParams.parent_id, + fields: Object.keys(runParams), + }); + return await this.client.runImport(id, runParams); + }, + }; + } + + /** Delete import tool */ + private createDeleteImportTool(): MCPTool { + return { + name: 'bookstack_imports_delete', + description: `Permanently delete a pending ZIP import and its stored file without running it. Content already imported is unaffected. ${IMPORT_PERMISSION_NOTE}`, + inputSchema: { + type: 'object', + required: ['id'], + properties: { + id: { + type: 'integer', + minimum: 1, + description: 'The ID of the import to delete', + }, + }, + }, + examples: [ + { + description: 'Discard an import', + input: { id: 31 }, + expected_output: 'Success message', + use_case: 'Removing a ZIP uploaded by mistake', + }, + ], + usage_patterns: ['Confirm ID before deleting as this is permanent'], + related_tools: ['bookstack_imports_list'], + error_codes: [ + { + code: 'NOT_FOUND', + description: 'Import not found, already run, or not yours', + recovery_suggestion: 'Verify ID', + }, + ], + handler: async (params: unknown) => { + const { id } = this.validator.validateParams(params, 'id'); + this.logger.warn('Deleting import', { id }); + await this.client.deleteImport(id); + return { success: true, message: `Import ${id} deleted successfully` }; + }, + }; + } +} + +export default ImportTools; diff --git a/src/tools/pages.ts b/src/tools/pages.ts index d5f1d3e..7487a9a 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -1226,7 +1226,7 @@ export class PageTools { return { name: 'bookstack_pages_export', description: - 'Export a page to a specific format (HTML, PDF, Markdown, Plain Text). Returns { content, encoding, byte_length, filename, mime_type }: text formats arrive as-is with encoding "utf8", while "pdf" arrives base64-encoded with encoding "base64".', + 'Export a page to a specific format (HTML, PDF, Markdown, Plain Text, ZIP). Returns { content, encoding, byte_length, filename, mime_type }: text formats arrive as-is with encoding "utf8", while "pdf" and "zip" arrive base64-encoded with encoding "base64". "zip" is the portable format bookstack_imports_create accepts, and requires BookStack v25.07+.', inputSchema: { type: 'object', required: ['id', 'format'], @@ -1238,8 +1238,8 @@ export class PageTools { }, format: { type: 'string', - enum: ['html', 'pdf', 'plaintext', 'markdown'], - description: 'Desired format.', + enum: ['html', 'pdf', 'plaintext', 'markdown', 'zip'], + description: 'Desired export format. "zip" requires BookStack v25.07+.', }, }, }, @@ -1253,9 +1253,9 @@ export class PageTools { ], usage_patterns: [ 'Use "plaintext" or "markdown" for processing text in LLMs', - 'Check `encoding` before using `content`: for "pdf" it is base64 and must be decoded to bytes, not read as text. Use `byte_length` for the real file size - `content.length` counts characters, not bytes.', + 'Check `encoding` before using `content`: for "pdf" and "zip" it is base64 and must be decoded to bytes, not read as text. Use `byte_length` for the real file size - `content.length` counts characters, not bytes.', ], - related_tools: ['bookstack_books_export'], + related_tools: ['bookstack_books_export', 'bookstack_imports_create'], error_codes: [ { code: 'NOT_FOUND', diff --git a/src/tools/server-info.ts b/src/tools/server-info.ts index 267a133..7b559c2 100644 --- a/src/tools/server-info.ts +++ b/src/tools/server-info.ts @@ -248,6 +248,9 @@ export class ServerInfoTools { 'recyclebin', 'attachments', 'images', + 'comments', + 'imports', + 'tags', 'meta', ], description: 'Specific category name. Omit to list every category.', @@ -607,10 +610,48 @@ export class ServerInfoTools { use_cases: ['Upload images', 'Manage gallery assets'], }, { - // Without this entry the categories described 54 of the server's 59 tools: - // the five self-describing tools belonged to no category, so the listing an - // LLM consults to find out what exists omitted the tools that tell it what - // exists. They all declare `category: 'meta'` themselves. + name: 'comments', + description: 'Discuss pages - list, add, reply to, edit, archive and delete page comments', + tools: [ + 'bookstack_comments_list', + 'bookstack_comments_create', + 'bookstack_comments_read', + 'bookstack_comments_update', + 'bookstack_comments_delete', + ], + use_cases: [ + 'Leave review feedback on a page', + 'Follow and answer a discussion thread', + 'Archive resolved comments', + ], + }, + { + name: 'imports', + description: + 'Import BookStack ZIP exports - upload a ZIP, inspect it, then run it to create its book, chapter or page', + tools: [ + 'bookstack_imports_list', + 'bookstack_imports_create', + 'bookstack_imports_read', + 'bookstack_imports_run', + 'bookstack_imports_delete', + ], + use_cases: [ + 'Move content between BookStack instances', + 'Restore a book, chapter or page from a ZIP export', + ], + }, + { + name: 'tags', + description: 'Discover the tag names and values in use across visible content', + tools: ['bookstack_tags_list_names', 'bookstack_tags_list_values'], + use_cases: [ + 'Learn the tagging vocabulary before tagging new content', + 'Build [name=value] search filters', + ], + }, + { + // The self-describing tools all declare `category: 'meta'`. name: 'meta', description: 'Ask this server about itself - its tools, resources and conventions', tools: [ @@ -923,8 +964,33 @@ export class ServerInfoTools { description: 'PDF is binary: content arrives base64-encoded with encoding "base64", and byte_length carries the true file size', }, + { + step: 4, + action: 'Export a ZIP to move the book to another BookStack', + tool_or_resource: 'bookstack_books_export', + parameters: { id: 1, format: 'zip' }, + description: + "ZIP is BookStack's portable format, with images and attachments included; like PDF it arrives base64-encoded", + }, + { + step: 5, + action: 'Upload the ZIP on the target instance', + tool_or_resource: 'bookstack_imports_create', + parameters: { file: '' }, + description: + 'Validates and stores the ZIP without creating anything; the result names the import id and type', + }, + { + step: 6, + action: 'Run the import', + tool_or_resource: 'bookstack_imports_run', + parameters: { id: 1 }, + description: + 'Creates the content; a chapter or page import also needs parent_type and parent_id', + }, ], - expected_outcome: 'A local copy of the content in a format you can archive or re-import', + expected_outcome: + 'A local copy of the content in a format you can archive, or the same content recreated on another instance', }, ]; } @@ -1012,6 +1078,8 @@ export class ServerInfoTools { 'Create books for major topics', 'Use chapters to organize pages', 'Add meaningful descriptions and tags', + 'Reuse existing tag names: bookstack_tags_list_names shows the vocabulary in use', + 'Leave review feedback with bookstack_comments_create rather than editing the page', ], workflow: [ 'Plan structure first', @@ -1053,6 +1121,7 @@ export class ServerInfoTools { 'Start broad, then narrow with a {type:...} or tag filter once you see the shape of the results', 'Search returns previews, not full content - follow up with bookstack_pages_read for the real text', 'If a search comes back empty, retry with fewer or more general words before concluding nothing exists', + 'Use bookstack_tags_list_names and bookstack_tags_list_values to find the tag names and values worth filtering on', ], }, best_practices: { @@ -1064,11 +1133,11 @@ export class ServerInfoTools { ], efficiency: [ 'Use list tools with filters instead of fetching everything and filtering locally', - 'Use markdown or plaintext exports for LLM context; html and pdf cost far more tokens', + 'Use markdown or plaintext exports for LLM context; html, pdf and zip cost far more tokens', 'There is no batch tool: each call handles one item, so prefer a filtered list call over many individual reads, and avoid polling in a loop - outbound calls are rate-limited', ], safety: [ - 'Deletes are recoverable: entity deletes land in the recycle bin, reachable via bookstack_recyclebin_list', + 'Book, chapter, page and shelf deletes land in the recycle bin, reachable via bookstack_recyclebin_list; comment and import deletes are permanent', 'Check bookstack_permissions_read before assuming content is private', 'Use bookstack_audit_log_list to find out who changed something and when', ], diff --git a/src/tools/tags.ts b/src/tools/tags.ts new file mode 100644 index 0000000..b0de6ba --- /dev/null +++ b/src/tools/tags.ts @@ -0,0 +1,217 @@ +import type { BookStackClient } from '../api/client'; +import { + type MCPTool, + NONBLANK_PATTERN, + type TagNamesListParams, + type TagValuesListParams, + withClosedSchemas, +} from '../types'; +import type { Logger } from '../utils/logger'; +import type { ValidationHandler } from '../validation/validator'; + +/** The BookStack release that added the tags API. */ +const TAGS_VERSION_NOTE = 'Requires BookStack v26.05+.'; + +/** Read-only tag listings; tags are written through the `tags` of the item they belong to. */ +export class TagTools { + constructor( + private client: BookStackClient, + private validator: ValidationHandler, + private logger: Logger + ) {} + + /** Get all tag tools */ + getTools(): MCPTool[] { + return withClosedSchemas([this.createListNamesTool(), this.createListValuesTool()]); + } + + /** List tag names tool */ + private createListNamesTool(): MCPTool { + return { + name: 'bookstack_tags_list_names', + description: `List the tag names in use, with how many distinct values each has and how many pages, chapters, books and shelves carry it. Only tags on content visible to the authenticated user are counted. ${TAGS_VERSION_NOTE}`, + category: 'tags', + inputSchema: { + type: 'object', + properties: { + count: { + type: 'integer', + minimum: 1, + maximum: 500, + default: 20, + description: 'Number of tag names to return', + }, + offset: { + type: 'integer', + minimum: 0, + default: 0, + description: 'Number of tag names to skip', + }, + sort: { + type: 'string', + enum: [ + 'name', + 'values', + 'usages', + 'page_count', + 'chapter_count', + 'book_count', + 'shelf_count', + '-name', + '-values', + '-usages', + '-page_count', + '-chapter_count', + '-book_count', + '-shelf_count', + ], + default: 'name', + description: 'Sort field. Prefix with "-" to sort descending, e.g. "-usages".', + }, + filter: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Filter by tag name, matched exactly', + }, + }, + description: 'Optional filter. BookStack filters tag names on `name` only.', + }, + }, + }, + examples: [ + { + description: 'Most used tag names first', + input: { sort: '-usages', count: 10 }, + expected_output: 'Array of { name, values, usages, page_count, ... }', + use_case: 'Learning the tagging vocabulary before searching by tag', + }, + ], + usage_patterns: [ + 'Use the names found here in bookstack_search as [name] or [name=value]', + 'Follow up with bookstack_tags_list_values to see the values one name takes', + ], + related_tools: ['bookstack_tags_list_values', 'bookstack_search'], + error_codes: [ + { + code: 'UNAUTHORIZED', + description: 'Authentication failed or insufficient permissions', + recovery_suggestion: 'Verify API token and permissions', + }, + ], + handler: async (params: unknown) => { + const validatedParams = this.validator.validateParams( + params, + 'tagNamesList' + ); + this.logger.debug('Listing tag names', { + count: validatedParams.count, + offset: validatedParams.offset, + sort: validatedParams.sort, + filters: Object.keys(validatedParams.filter ?? {}), + }); + return await this.client.listTagNames(validatedParams); + }, + }; + } + + /** List tag values tool */ + private createListValuesTool(): MCPTool { + return { + name: 'bookstack_tags_list_values', + description: `List the values used with one tag name, with how many pages, chapters, books and shelves carry each. Only tags on content visible to the authenticated user are counted. ${TAGS_VERSION_NOTE}`, + category: 'tags', + inputSchema: { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'string', + minLength: 1, + pattern: NONBLANK_PATTERN, + description: 'The tag name whose values to list, matched exactly', + }, + count: { + type: 'integer', + minimum: 1, + maximum: 500, + default: 20, + description: 'Number of values to return', + }, + offset: { + type: 'integer', + minimum: 0, + default: 0, + description: 'Number of values to skip', + }, + sort: { + type: 'string', + enum: [ + 'name', + 'value', + 'usages', + 'page_count', + 'chapter_count', + 'book_count', + 'shelf_count', + '-name', + '-value', + '-usages', + '-page_count', + '-chapter_count', + '-book_count', + '-shelf_count', + ], + default: 'value', + description: 'Sort field. Prefix with "-" to sort descending, e.g. "-usages".', + }, + filter: { + type: 'object', + properties: { + value: { + type: 'string', + description: 'Filter by tag value, matched exactly', + }, + }, + description: 'Optional filter. BookStack filters tag values on `value` only.', + }, + }, + }, + examples: [ + { + description: 'Values of the Category tag', + input: { name: 'Category' }, + expected_output: 'Array of { name, value, usages, page_count, ... }', + use_case: 'Choosing a value for a [Category=...] search', + }, + ], + usage_patterns: ['Take name from bookstack_tags_list_names'], + related_tools: ['bookstack_tags_list_names', 'bookstack_search'], + error_codes: [ + { + code: 'VALIDATION_ERROR', + description: 'name missing or blank', + recovery_suggestion: 'Pass the tag name to list values for', + }, + ], + handler: async (params: unknown) => { + const validatedParams = this.validator.validateParams( + params, + 'tagValuesList' + ); + // Log the tag name's length, never the name itself. + this.logger.debug('Listing tag values', { + name_length: validatedParams.name.length, + count: validatedParams.count, + offset: validatedParams.offset, + sort: validatedParams.sort, + filters: Object.keys(validatedParams.filter ?? {}), + }); + return await this.client.listTagValues(validatedParams); + }, + }; + } +} + +export default TagTools; diff --git a/src/types.ts b/src/types.ts index c5fb8b8..ad7adac 100644 --- a/src/types.ts +++ b/src/types.ts @@ -548,6 +548,117 @@ export interface AuditLogEntry { user: UserSummary; } +/** A comment from create, update or list; only a read includes `html`. */ +export interface Comment { + id: number; + /** The page the comment is on. */ + commentable_id: number; + /** 'page': BookStack only supports comments on pages. */ + commentable_type: string; + /** The `local_id` of the comment this replies to, on the same page; null at top level. */ + parent_id: number | null; + /** Numbers the comment within its page, from 1. */ + local_id: number; + /** The page text the comment is anchored to, or '' when it is not anchored. */ + content_ref: string; + created_by: number; + updated_by: number; + created_at: string; + updated_at: string; + /** Absent from list responses. */ + archived?: boolean; + /** The page, attached by an update that changed `html` or `archived`. */ + entity?: CommentEntity; +} + +/** The page a comment is on, as BookStack embeds it in an update response. */ +export interface CommentEntity { + id: number; + /** 'page'. */ + type: string; + name: string; + slug: string; + book_id: number; + chapter_id: number | null; + priority: number; + created_at: string; + updated_at: string; + created_by: number; + updated_by: number; + owned_by: number; + page_id: number; + draft: boolean; + template: boolean; + revision_count: number; + editor: string; +} + +/** A direct reply, as embedded in `CommentDetail.replies`. */ +export interface CommentReply extends Omit { + html: string; + archived: boolean; +} + +/** A comment as returned by `GET /api/comments/{id}`, with its direct replies. */ +export interface CommentDetail extends Omit { + created_by: UserSummary; + updated_by: UserSummary; + html: string; + archived: boolean; + replies: CommentReply[]; +} + +/** What a ZIP import will create once run. */ +export type ImportType = 'book' | 'chapter' | 'page'; + +/** A pending ZIP import as returned by `GET /api/imports`. */ +export interface Import { + id: number; + /** The name of the top-level item in the ZIP. */ + name: string; + /** ZIP size in bytes. */ + size: number; + type: ImportType; + created_by: number; + created_at: string; + updated_at: string; +} + +/** A pending ZIP import as returned by `POST /api/imports`, with its storage path. */ +export interface ImportCreateResult extends Import { + path: string; +} + +/** A pending ZIP import as returned by `GET /api/imports/{id}`; `details` is shaped by `type`. */ +export interface ImportDetail extends ImportCreateResult { + details: Record | null; +} + +/** What `POST /api/imports/{id}` returns: the created book, chapter or page. */ +export type ImportRunResult = Book | Chapter | Page; + +/** The usage counts both tag listings carry. */ +interface TagUsageCounts { + usages: number; + page_count: number; + chapter_count: number; + book_count: number; + shelf_count: number; +} + +/** An entry of `GET /api/tags/names`. */ +export interface TagNameSummary extends TagUsageCounts { + name: string; + /** How many distinct values the name is used with. */ + values: number; +} + +/** An entry of `GET /api/tags/values-for-name`. */ +export interface TagValueSummary extends TagUsageCounts { + name: string; + value: string; +} + /** * The payload of `GET /api/system`. * @@ -757,6 +868,41 @@ export interface SearchParams { count?: number; } +export interface CommentsListParams extends PaginationParams { + filter?: { + commentable_id?: number; + commentable_type?: 'page'; + parent_id?: number; + local_id?: number; + content_ref?: string; + created_by?: number; + updated_by?: number; + }; +} + +export interface ImportsListParams extends PaginationParams { + filter?: { + name?: string; + size?: number; + type?: ImportType; + created_by?: number; + }; +} + +export interface TagNamesListParams extends PaginationParams { + filter?: { + name?: string; + }; +} + +/** `name` is the tag name whose values are listed, sent as a query parameter. */ +export interface TagValuesListParams extends PaginationParams { + name: string; + filter?: { + value?: string; + }; +} + export interface ImageGalleryListParams extends PaginationParams { filter?: { name?: string; @@ -1052,6 +1198,31 @@ export interface UpdateImageParams { file_path?: string; // server-local path, read by the server (access-controlled) } +export interface CreateCommentParams { + page_id: number; + html: string; + /** The `local_id` of the comment to reply to, on the same page. */ + reply_to?: number; + /** See COMMENT_CONTENT_REF_PATTERN. */ + content_ref?: string; +} + +export interface UpdateCommentParams { + html?: string; + archived?: boolean; +} + +/** Exactly one source for the ZIP; `file_path` is read by this server, never forwarded. */ +export type CreateImportParams = + | { file: string; file_path?: undefined } + | { file_path: string; file?: undefined }; + +/** Required by BookStack for a chapter or page import, and ignored for a book import. */ +export interface RunImportParams { + parent_type?: 'book' | 'chapter'; + parent_id?: number; +} + /** * The fallback (non-role-specific) permission block. * @@ -1273,6 +1444,15 @@ export function trimmedMinLengthPattern(min: number): string { */ export const LANGUAGE_PATTERN = '^[A-Za-z0-9_-]+$'; +/** CommentRepo::create()'s content_ref format; BookStack stores anything else as ''. */ +export const COMMENT_CONTENT_REF_PATTERN = '^bkmrk-(.*?):\\d+:(\\d*-\\d*)?$'; + +/** The documented upload ceiling of 50000 KB, in bytes. */ +export const UPLOAD_MAX_BYTES = 50_000 * 1024; + +/** Padded base64 length of UPLOAD_MAX_BYTES (68,266,668 characters). */ +export const UPLOAD_MAX_BASE64_LENGTH = Math.ceil(UPLOAD_MAX_BYTES / 3) * 4; + /** * Close an input schema: every object within it forbids unknown properties. * @@ -1364,7 +1544,13 @@ export interface ResourceExample { } // Export format types -export type ExportFormat = 'html' | 'pdf' | 'plaintext' | 'markdown'; +export type ExportFormat = 'html' | 'pdf' | 'plaintext' | 'markdown' | 'zip'; + +/** Formats whose bytes are binary, so an export of them is returned base64-encoded. */ +export type BinaryExportFormat = 'pdf' | 'zip'; + +/** Formats whose payload is text, so an export of them is returned as UTF-8. */ +export type TextExportFormat = Exclude; /** * The result of an export. @@ -1374,7 +1560,7 @@ export type ExportFormat = 'html' | 'pdf' | 'plaintext' | 'markdown'; * payload, and `encoding` is what tells them apart: * * - `utf8` - `content` is the text itself (html, plaintext, markdown). - * - `base64` - `content` is the base64 encoding of binary bytes (pdf). + * - `base64` - `content` is the base64 encoding of binary bytes (pdf, zip). * Text-decoding those bytes would corrupt them irrecoverably. * * `content.length` is a count of *characters*, which for base64 is neither the @@ -1463,6 +1649,24 @@ export interface BookStackAPIClient { updateImage(id: number, params: UpdateImageParams): Promise; deleteImage(id: number): Promise; + // Comments + listComments(params?: CommentsListParams): Promise>; + createComment(params: CreateCommentParams): Promise; + getComment(id: number): Promise; + updateComment(id: number, params: UpdateCommentParams): Promise; + deleteComment(id: number): Promise; + + // Imports + listImports(params?: ImportsListParams): Promise>; + createImport(params: CreateImportParams): Promise; + getImport(id: number): Promise; + runImport(id: number, params: RunImportParams): Promise; + deleteImport(id: number): Promise; + + // Tags + listTagNames(params?: TagNamesListParams): Promise>; + listTagValues(params: TagValuesListParams): Promise>; + // Search search(params: SearchParams): Promise>; diff --git a/src/utils/errors.ts b/src/utils/errors.ts index d03a601..dab4911 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -203,6 +203,14 @@ function sanitizeWait(waitMs: number | undefined): number | undefined { return Math.min(waitMs, MAX_SERVER_DIRECTED_WAIT_MS); } +/** The upload guard refused a `file_path`; the caller's input, and its message names no path. */ +export class UploadRefusedError extends Error { + constructor(message: string) { + super(message); + this.name = 'UploadRefusedError'; + } +} + /** * Error handler for BookStack MCP Server */ @@ -302,6 +310,13 @@ export class ErrorHandler { }); } + if (error instanceof UploadRefusedError) { + return new McpError(ErrorCode.InvalidParams, error.message, { + type: 'validation_error', + validation: [{ field: 'file_path', message: error.message }], + }); + } + // Handle validation errors from Zod if (err.name === 'ZodError') { const validationDetails = (error as ZodError).issues.map((issue) => ({ diff --git a/src/validation/validator.ts b/src/validation/validator.ts index 305b5d6..4618555 100644 --- a/src/validation/validator.ts +++ b/src/validation/validator.ts @@ -1,10 +1,12 @@ import { z } from 'zod'; import { + COMMENT_CONTENT_REF_PATTERN, type ContentType, type ExportFormat, LANGUAGE_PATTERN, NONBLANK_PATTERN, trimmedMinLengthPattern, + UPLOAD_MAX_BASE64_LENGTH, } from '../types'; import { Logger } from '../utils/logger'; @@ -126,6 +128,27 @@ const language = z 'language may only contain letters, numbers, dashes and underscores' ); +/** A comment's `content_ref`, compiled from the published pattern. */ +const commentContentRef = z + .string() + .max(255) + .regex( + new RegExp(COMMENT_CONTENT_REF_PATTERN), + "content_ref must look like 'bkmrk-::-': BookStack stores any other value as ''" + ); + +/** A comment body: required upstream, so it must hold a non-whitespace character. */ +const commentHtml = z.string().min(1).regex(NONBLANK, NONBLANK_MESSAGE); + +/** Inline base64 upload content, capped at the documented 50000 KB. */ +const base64Upload = z + .string() + .min(1) + .max( + UPLOAD_MAX_BASE64_LENGTH, + `must decode to at most 50000 KB (${UPLOAD_MAX_BASE64_LENGTH} base64 characters)` + ); + /** `count` on a listing. 500 is BookStack's own maximum, and it REJECTS more (see below). */ const listCount = z.number().int().min(1).max(500).default(20); @@ -738,7 +761,7 @@ const ValidationSchemas = { .strictObject({ uploaded_to: entityId, name: z.string().min(1).max(255), - file: z.string().min(1).optional(), // base64 encoded + file: base64Upload.optional(), file_path: z.string().min(1).optional(), // server-local path link: z.string().url().max(2000).optional(), }) @@ -766,7 +789,7 @@ const ValidationSchemas = { id: entityId, uploaded_to: entityId.optional(), name: z.string().min(1).max(255).optional(), - file: z.string().min(1).optional(), // base64 encoded + file: base64Upload.optional(), file_path: z.string().min(1).optional(), // server-local path link: z.string().url().max(2000).optional(), }) @@ -807,7 +830,7 @@ const ValidationSchemas = { imageCreate: z .strictObject({ name: z.string().min(1).max(180).optional(), - image: z.string().min(1).optional(), // base64 encoded + image: base64Upload.optional(), file_path: z.string().min(1).optional(), // server-local path type: z.enum(['gallery', 'drawio']).default('gallery'), uploaded_to: entityId, // required by BookStack @@ -823,13 +846,185 @@ const ValidationSchemas = { .strictObject({ id: entityId, name: z.string().min(1).max(180).optional(), - image: z.string().min(1).optional(), // base64 encoded + image: base64Upload.optional(), file_path: z.string().min(1).optional(), // server-local path }) .refine((data) => !(data.image && data.file_path), { message: 'Provide either image (base64 content) or file_path (a server-local path), not both', }), + // Comments: sorts are every field CommentApiController::list() exposes, upstream default `id`. + commentsList: z.strictObject({ + count: listCount, + offset: listOffset, + sort: z + .enum([ + 'id', + 'commentable_id', + 'commentable_type', + 'parent_id', + 'local_id', + 'content_ref', + 'created_by', + 'updated_by', + 'created_at', + 'updated_at', + '-id', + '-commentable_id', + '-commentable_type', + '-parent_id', + '-local_id', + '-content_ref', + '-created_by', + '-updated_by', + '-created_at', + '-updated_at', + ]) + .default('id'), + filter: z + .strictObject({ + commentable_id: entityId.optional(), + commentable_type: z.enum(['page']).optional(), + parent_id: entityId.optional(), + local_id: entityId.optional(), + content_ref: z.string().optional(), + created_by: entityId.optional(), + updated_by: entityId.optional(), + }) + .optional(), + }), + + commentCreate: z.strictObject({ + page_id: entityId, + html: commentHtml, + reply_to: entityId.optional(), + content_ref: commentContentRef.optional(), + }), + + // `html` is nonblank here too: BookStack would store a blank update as an empty comment. + commentUpdate: z + .strictObject({ + id: entityId, + html: commentHtml.optional(), + archived: z.boolean().optional(), + }) + .refine((data) => data.html !== undefined || data.archived !== undefined, { + message: 'Provide html, archived, or both: an update with neither changes nothing', + }), + + // Imports: sorts are every field ImportApiController::list() exposes, upstream default `id`. + importsList: z.strictObject({ + count: listCount, + offset: listOffset, + sort: z + .enum([ + 'id', + 'name', + 'size', + 'type', + 'created_by', + 'created_at', + 'updated_at', + '-id', + '-name', + '-size', + '-type', + '-created_by', + '-created_at', + '-updated_at', + ]) + .default('id'), + filter: z + .strictObject({ + name: z.string().optional(), + size: z.number().int().min(0).optional(), + type: z.enum(['book', 'chapter', 'page']).optional(), + created_by: entityId.optional(), + }) + .optional(), + }), + + importCreate: z + .strictObject({ + file: base64Upload.optional(), + file_path: z.string().min(1).optional(), // server-local path + }) + .refine((data) => Boolean(data.file || data.file_path), { + message: 'Either file (base64 content) or file_path (a server-local path) is required', + }) + .refine((data) => !(data.file && data.file_path), { + message: 'Provide either file (base64 content) or file_path (a server-local path), not both', + }), + + importRun: z + .strictObject({ + id: entityId, + parent_type: z.enum(['book', 'chapter']).optional(), + parent_id: entityId.optional(), + }) + .refine((data) => (data.parent_type === undefined) === (data.parent_id === undefined), { + message: 'Send parent_type and parent_id together, or neither', + }), + + // Tags: names filter only on `name`, values only on `value` (TagApiController). + tagNamesList: z.strictObject({ + count: listCount, + offset: listOffset, + sort: z + .enum([ + 'name', + 'values', + 'usages', + 'page_count', + 'chapter_count', + 'book_count', + 'shelf_count', + '-name', + '-values', + '-usages', + '-page_count', + '-chapter_count', + '-book_count', + '-shelf_count', + ]) + .default('name'), + filter: z + .strictObject({ + name: z.string().optional(), + }) + .optional(), + }), + + // Defaults to `value`: upstream falls back to `name`, which is the same on every row here. + tagValuesList: z.strictObject({ + name: z.string().min(1).regex(NONBLANK, NONBLANK_MESSAGE), + count: listCount, + offset: listOffset, + sort: z + .enum([ + 'name', + 'value', + 'usages', + 'page_count', + 'chapter_count', + 'book_count', + 'shelf_count', + '-name', + '-value', + '-usages', + '-page_count', + '-chapter_count', + '-book_count', + '-shelf_count', + ]) + .default('value'), + filter: z + .strictObject({ + value: z.string().optional(), + }) + .optional(), + }), + // Search // BookStack's search caps `count` at 100 rather than the 500 the listings allow. // @@ -927,7 +1122,7 @@ const ValidationSchemas = { // Export export: z.strictObject({ id: entityId, - format: z.enum(['html', 'pdf', 'plaintext', 'markdown']), + format: z.enum(['html', 'pdf', 'plaintext', 'markdown', 'zip']), }), // Generic ID parameter diff --git a/tests/helpers/strict-tools.ts b/tests/helpers/strict-tools.ts index 6808b1d..8e6c402 100644 --- a/tests/helpers/strict-tools.ts +++ b/tests/helpers/strict-tools.ts @@ -16,7 +16,9 @@ import { AttachmentTools } from '../../src/tools/attachments'; import { AuditTools } from '../../src/tools/audit'; import { BookTools } from '../../src/tools/books'; import { ChapterTools } from '../../src/tools/chapters'; +import { CommentTools } from '../../src/tools/comments'; import { ImageTools } from '../../src/tools/images'; +import { ImportTools } from '../../src/tools/imports'; import { PageTools } from '../../src/tools/pages'; import { PermissionTools } from '../../src/tools/permissions'; import { RecycleBinTools } from '../../src/tools/recyclebin'; @@ -25,6 +27,7 @@ import { SearchTools } from '../../src/tools/search'; import { ServerInfoTools } from '../../src/tools/server-info'; import { ShelfTools } from '../../src/tools/shelves'; import { SystemTools } from '../../src/tools/system'; +import { TagTools } from '../../src/tools/tags'; import { UserTools } from '../../src/tools/users'; import type { MCPResource, MCPTool } from '../../src/types'; import type { Logger } from '../../src/utils/logger'; @@ -96,6 +99,9 @@ export function buildTools(client: BookStackClient): Map { new RoleTools(client, validator, silentLogger), new AttachmentTools(client, validator, silentLogger), new ImageTools(client, validator, silentLogger), + new CommentTools(client, validator, silentLogger), + new ImportTools(client, validator, silentLogger), + new TagTools(client, validator, silentLogger), new SearchTools(client, validator, silentLogger), new RecycleBinTools(client, validator, silentLogger), new PermissionTools(client, validator, silentLogger), diff --git a/tests/integration/attachments.test.ts b/tests/integration/attachments.test.ts index d2e3e4f..3d65723 100644 --- a/tests/integration/attachments.test.ts +++ b/tests/integration/attachments.test.ts @@ -626,7 +626,9 @@ describe.skipIf(!runIntegration)('BookStack attachment tools (live)', () => { name, file_path: traversal, }) - ).rejects.toThrow(/must name a readable file inside BOOKSTACK_UPLOAD_ROOT/); + ).rejects.toThrow( + /must name a readable regular file of at most 50000 KB inside BOOKSTACK_UPLOAD_ROOT/ + ); const listed = (await runTool('bookstack_attachments_list', { filter: { uploaded_to: pageId, name }, diff --git a/tests/integration/books.test.ts b/tests/integration/books.test.ts index 2e78546..3ca499e 100644 --- a/tests/integration/books.test.ts +++ b/tests/integration/books.test.ts @@ -47,11 +47,11 @@ import { PageTools } from '../../src/tools/pages'; import type { Book, Chapter, - ExportFormat, ExportResult, ListResponse, MCPTool, Page, + TextExportFormat, } from '../../src/types'; import { ErrorHandler } from '../../src/utils/errors'; import { Logger } from '../../src/utils/logger'; @@ -174,9 +174,6 @@ function uniqueName(label: string): string { return `itest-books-${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -/** Every export format whose payload is text rather than binary. */ -type TextExportFormat = Exclude; - /** * What the client must report per text format. BookStack labels every export * `application/octet-stream`, so `mime_type` is derived from the requested diff --git a/tests/integration/chapters.test.ts b/tests/integration/chapters.test.ts index 260c310..1bbc795 100644 --- a/tests/integration/chapters.test.ts +++ b/tests/integration/chapters.test.ts @@ -38,11 +38,11 @@ import type { Book, Chapter, ChapterWithPages, - ExportFormat, ExportResult, ListResponse, MCPTool, Page, + TextExportFormat, UserRef, } from '../../src/types'; import { ErrorHandler } from '../../src/utils/errors'; @@ -163,9 +163,6 @@ function userRefId(ref: UserRef): number { return typeof ref === 'number' ? ref : ref.id; } -/** Every export format whose payload is text rather than binary. */ -type TextExportFormat = Exclude; - /** * What the client must report per text format. BookStack labels every export * `application/octet-stream`, so `mime_type` is derived from the requested diff --git a/tests/integration/comments-tags-imports.test.ts b/tests/integration/comments-tags-imports.test.ts new file mode 100644 index 0000000..74f11ba --- /dev/null +++ b/tests/integration/comments-tags-imports.test.ts @@ -0,0 +1,218 @@ +// Live coverage for the comment, tag and import tools and ZIP export; needs RUN_INTEGRATION. + +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import { Buffer } from 'node:buffer'; +import { BookStackClient } from '../../src/api/client'; +import type { Config } from '../../src/config/manager'; +import { CommentTools } from '../../src/tools/comments'; +import { ImportTools } from '../../src/tools/imports'; +import { PageTools } from '../../src/tools/pages'; +import { TagTools } from '../../src/tools/tags'; +import type { MCPTool } from '../../src/types'; +import { ErrorHandler } from '../../src/utils/errors'; +import { Logger } from '../../src/utils/logger'; +import { ValidationHandler } from '../../src/validation/validator'; +import { + apiFetchRetrying, + apiJson, + type BookStackHarness, + CleanupTracker, + ensureBookStack, + shouldRunIntegration, +} from './helpers/bookstack'; + +const TEST_TIMEOUT_MS = 180_000; +const RATE_LIMIT_ATTEMPTS = 10; +const RATE_LIMIT_BACKOFF_MS = 8000; + +function uniqueName(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function isRateLimited(error: unknown): boolean { + return (error as { data?: { status?: number } } | null)?.data?.status === 429; +} + +async function withRateLimitRetry(operation: () => Promise): Promise { + for (let attempt = 1; ; attempt += 1) { + try { + return await operation(); + } catch (error) { + if (attempt >= RATE_LIMIT_ATTEMPTS || !isRateLimited(error)) { + throw error; + } + await Bun.sleep(RATE_LIMIT_BACKOFF_MS); + } + } +} + +// biome-ignore lint/suspicious/noExplicitAny: tool results are asserted field by field below +type Json = any; + +const runIntegration = await shouldRunIntegration(); + +describe.skipIf(!runIntegration)('comment, tag and import tools (live BookStack)', () => { + let harness: BookStackHarness; + let tools: MCPTool[]; + let bookId: number; + let chapterId: number; + let pageId: number; + const tagName = uniqueName('itest-tag'); + const cleanup = new CleanupTracker(); + + const run = async (name: string, params: Record): Promise => { + const tool = tools.find((candidate) => candidate.name === name); + if (!tool) { + throw new Error(`Expected tool ${name} to be registered`); + } + return await withRateLimitRetry(async () => await tool.handler(params)); + }; + + const createRaw = async (path: string, body: Record): Promise => { + const res = await apiFetchRetrying(harness, path, { + method: 'POST', + body: JSON.stringify(body), + }); + expect(res.status).toBe(200); + return (await apiJson<{ id: number }>(res)).id; + }; + + beforeAll(async () => { + harness = await ensureBookStack(); + const logger = Logger.getInstance(); + const config: Config = { + bookstack: { baseUrl: harness.baseUrl, apiToken: harness.token, timeout: 30_000 }, + server: { name: 'bookstack-mcp-server-itest', version: '1.0.0', port: 3000 }, + rateLimit: { requestsPerMinute: 120, burstLimit: 12 }, + validation: { enabled: true, strictMode: true }, + logging: { level: 'error', format: 'json' }, + }; + const client = new BookStackClient(config, logger, new ErrorHandler(logger)); + const validator = new ValidationHandler({ enabled: true, strictMode: true }); + tools = [ + ...new CommentTools(client, validator, logger).getTools(), + ...new TagTools(client, validator, logger).getTools(), + ...new ImportTools(client, validator, logger).getTools(), + ...new PageTools(client, validator, logger).getTools(), + ]; + + bookId = await createRaw('/books', { name: uniqueName('itest-extras-book') }); + cleanup.track('book', bookId); + chapterId = await createRaw('/chapters', { + book_id: bookId, + name: uniqueName('itest-extras-chapter'), + }); + cleanup.track('chapter', chapterId); + pageId = await createRaw('/pages', { + book_id: bookId, + name: uniqueName('itest-extras-page'), + html: '

Body for comments, tags and exports.

', + tags: [ + { name: tagName, value: 'Alpha' }, + { name: tagName, value: 'Beta' }, + ], + }); + cleanup.track('page', pageId); + }, TEST_TIMEOUT_MS); + + afterAll(async () => { + if (harness) { + await cleanup.run(harness); + } + }, TEST_TIMEOUT_MS); + + it( + 'creates, lists, reads, updates and deletes comments with a reply', + async () => { + const top = await run('bookstack_comments_create', { + page_id: pageId, + html: '

Top-level comment

', + }); + expect(top.parent_id).toBeNull(); + expect(top.archived).toBe(false); + + const reply = await run('bookstack_comments_create', { + page_id: pageId, + html: '

A reply

', + reply_to: top.local_id, + }); + expect(reply.parent_id).toBe(top.local_id); + + const listed = await run('bookstack_comments_list', { + filter: { commentable_id: pageId }, + sort: '-id', + }); + expect(listed.total).toBe(2); + expect(listed.data.map((comment: Json) => comment.id)).toEqual([reply.id, top.id]); + + const read = await run('bookstack_comments_read', { id: top.id }); + expect(read.html).toContain('Top-level comment'); + expect(read.replies.map((comment: Json) => comment.id)).toEqual([reply.id]); + + await run('bookstack_comments_update', { id: top.id, html: '

Edited comment

' }); + expect((await run('bookstack_comments_read', { id: top.id })).html).toContain( + 'Edited comment' + ); + expect( + (await run('bookstack_comments_update', { id: top.id, archived: true })).archived + ).toBe(true); + expect( + (await run('bookstack_comments_update', { id: top.id, archived: false })).archived + ).toBe(false); + + await run('bookstack_comments_delete', { id: reply.id }); + await run('bookstack_comments_delete', { id: top.id }); + await expect(run('bookstack_comments_read', { id: top.id })).rejects.toBeDefined(); + }, + TEST_TIMEOUT_MS + ); + + it( + 'lists tag names and the values used for a name', + async () => { + const names = await run('bookstack_tags_list_names', { filter: { name: tagName } }); + expect(names.total).toBe(1); + // page_count counts tag uses on pages, so two values on one page count twice. + expect(names.data[0]).toMatchObject({ name: tagName, values: 2, usages: 2, page_count: 2 }); + + const values = await run('bookstack_tags_list_values', { name: tagName, sort: 'value' }); + expect(values.data.map((row: Json) => row.value)).toEqual(['Alpha', 'Beta']); + + const unknown = await run('bookstack_tags_list_values', { name: uniqueName('itest-none') }); + expect(unknown.total).toBe(0); + }, + TEST_TIMEOUT_MS + ); + + it( + 'exports a page as a zip, imports it into a chapter, and deletes an import', + async () => { + const exported = await run('bookstack_pages_export', { id: pageId, format: 'zip' }); + expect(exported.encoding).toBe('base64'); + expect(exported.mime_type).toBe('application/zip'); + const zip = Buffer.from(exported.content, 'base64'); + expect(zip.subarray(0, 4).toString('hex')).toBe('504b0304'); + + const created = await run('bookstack_imports_create', { file: exported.content }); + expect(created.type).toBe('page'); + + const listed = await run('bookstack_imports_list', { filter: { type: 'page' }, sort: '-id' }); + expect(listed.data.some((entry: Json) => entry.id === created.id)).toBe(true); + expect((await run('bookstack_imports_read', { id: created.id })).details).toBeDefined(); + + const imported = await run('bookstack_imports_run', { + id: created.id, + parent_type: 'chapter', + parent_id: chapterId, + }); + cleanup.track('page', imported.id); + expect(imported.chapter_id).toBe(chapterId); + await expect(run('bookstack_imports_read', { id: created.id })).rejects.toBeDefined(); + + const discarded = await run('bookstack_imports_create', { file: exported.content }); + await run('bookstack_imports_delete', { id: discarded.id }); + await expect(run('bookstack_imports_read', { id: discarded.id })).rejects.toBeDefined(); + }, + TEST_TIMEOUT_MS + ); +}); diff --git a/tests/integration/images.test.ts b/tests/integration/images.test.ts index bbfa40f..a86c756 100644 --- a/tests/integration/images.test.ts +++ b/tests/integration/images.test.ts @@ -803,7 +803,9 @@ describe.skipIf(!runIntegration)('BookStack image tools (live)', () => { await expect( runTool('bookstack_images_create', { name, file_path: traversal, uploaded_to: pageId }) - ).rejects.toThrow(/must name a readable file inside BOOKSTACK_UPLOAD_ROOT/); + ).rejects.toThrow( + /must name a readable regular file of at most 50000 KB inside BOOKSTACK_UPLOAD_ROOT/ + ); const listed = (await runTool('bookstack_images_list', { filter: { name }, diff --git a/tests/integration/pages.test.ts b/tests/integration/pages.test.ts index 6746f81..c802945 100644 --- a/tests/integration/pages.test.ts +++ b/tests/integration/pages.test.ts @@ -44,12 +44,12 @@ import { PageTools } from '../../src/tools/pages'; import type { Book, Chapter, - ExportFormat, ExportResult, ListResponse, MCPTool, Page, PageWithContent, + TextExportFormat, } from '../../src/types'; import { ErrorHandler } from '../../src/utils/errors'; import { Logger } from '../../src/utils/logger'; @@ -155,9 +155,6 @@ function uniqueName(label: string): string { return `itest-pages-${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -/** Every export format whose payload is text rather than binary. */ -type TextExportFormat = Exclude; - /** * What the client must report per text format. BookStack labels every export * `application/octet-stream`, so `mime_type` is derived from the requested @@ -783,6 +780,31 @@ describe.skipIf(!runIntegration)('bookstack_pages_* tools (live BookStack)', () expectPdfExport(pdf, page.slug); }, 120_000); + it('exports a page as a base64-encoded zip', async () => { + const page = track( + 'page', + await callTool(pageTools, 'bookstack_pages_create', { + chapter_id: parentChapter.id, + name: uniqueName('export-zip'), + markdown: '# Zip\n\nBody.', + }) + ); + + const zip = await callTool(pageTools, 'bookstack_pages_export', { + id: page.id, + format: 'zip', + }); + + expect(zip.encoding).toBe('base64'); + expect(zip.mime_type).toBe('application/zip'); + expect(zip.filename).toBe(`${page.slug}.zip`); + const decoded = Buffer.from(zip.content, 'base64'); + expect(decoded.byteLength).toBe(zip.byte_length); + // A zip archive opens with the local file header signature "PK\x03\x04". + expect(decoded.subarray(0, 2).toString('latin1')).toBe('PK'); + expect(decoded.subarray(0, 4).toString('hex')).toBe('504b0304'); + }, 120_000); + it('reports a missing page on export', async () => { await expect( callTool(pageTools, 'bookstack_pages_export', { id: 99_999_999, format: 'markdown' }) diff --git a/tests/transport/docker-smoke.test.ts b/tests/transport/docker-smoke.test.ts index 87b56c4..d51da92 100644 --- a/tests/transport/docker-smoke.test.ts +++ b/tests/transport/docker-smoke.test.ts @@ -249,7 +249,7 @@ describe.skipIf(!SMOKE_ENABLED)('built image', () => { const payload = (await authenticated.json()) as { result?: { tools?: Array<{ name: string }> }; }; - expect(payload.result?.tools).toHaveLength(59); + expect(payload.result?.tools).toHaveLength(71); }, STARTUP_TIMEOUT_MS + 15_000 ); diff --git a/tests/transport/stdio.test.ts b/tests/transport/stdio.test.ts index d18e01d..fb3bcfc 100644 --- a/tests/transport/stdio.test.ts +++ b/tests/transport/stdio.test.ts @@ -301,7 +301,7 @@ describe('stdio entry point', () => { }, 20_000); it( - 'completes an MCP handshake and lists all 59 tools', + 'completes an MCP handshake and lists all 71 tools', async () => { // Proof the spawned process is a working MCP server, not merely a quiet one: a // process that printed nothing at all would pass a stdout-purity check by itself. @@ -315,7 +315,7 @@ describe('stdio entry point', () => { const listReply = parseProtocolLine(await server.nextStdoutLine()); expect(listReply.id).toBe(2); - expect(listReply.result?.tools).toHaveLength(59); + expect(listReply.result?.tools).toHaveLength(71); expect(listReply.result?.tools?.map((tool) => tool.name)).toContain('bookstack_books_list'); }, REPLY_TIMEOUT_MS + 5_000 diff --git a/tests/transport/stub-bookstack.ts b/tests/transport/stub-bookstack.ts index 1b76c5b..7ba6d43 100644 --- a/tests/transport/stub-bookstack.ts +++ b/tests/transport/stub-bookstack.ts @@ -9,12 +9,14 @@ * validation, the axios client, retry and the response path, against a server that * answers like BookStack does. * - * It is not a BookStack emulator. It serves the handful of read-only endpoints the - * transport tests exercise, and answers anything else with a BookStack-shaped 404, so a - * test that silently starts calling an unimplemented endpoint fails rather than passes. + * It is not a BookStack emulator. It serves the handful of endpoints the transport tests + * exercise, with canned answers and no state, and answers anything else with a + * BookStack-shaped 404, so a test that silently starts calling an unimplemented endpoint + * fails rather than passes. * - * Every request is recorded. That is what makes assertions about *transmission* possible: - * a `count=2` that never reached the wire is a real bug this records the absence of. + * Every request is recorded, body included. That is what makes assertions about + * *transmission* possible: a `count=2` that never reached the wire is a real bug this + * records the absence of. */ /** One request the stub received, reduced to what a test might reasonably assert on. */ @@ -34,8 +36,17 @@ export interface RecordedRequest { * the server's view — the only one that settles it. */ userAgent: string | undefined; + /** The media type the request declared, without parameters such as the boundary. */ + contentType: string | undefined; + /** The parsed body of a JSON request; undefined for any other body. */ + body: unknown; + /** The fields of a multipart/form-data request; undefined for any other body. */ + form: Record | undefined; } +/** One multipart field: plain text, or a file part reduced to its name and bytes. */ +export type RecordedFormField = string | { filename: string; bytes: Buffer }; + export interface BookStackStub { /** Value for BOOKSTACK_BASE_URL: includes the /api suffix, as the real one does. */ baseUrl: string; @@ -89,6 +100,90 @@ const SYSTEM_INFO = { base_url: 'http://127.0.0.1/stub', }; +const STUB_USER = { id: 1, name: 'Stub Admin', slug: 'stub-admin' }; + +/** Comment list entries, shaped like GET /api/comments: no html, no archived flag. */ +const COMMENTS = [ + { + id: 1, + commentable_id: 3, + commentable_type: 'page', + parent_id: null, + local_id: 1, + content_ref: '', + created_by: 1, + updated_by: 1, + created_at: '2026-01-05T00:00:00.000000Z', + updated_at: '2026-01-05T00:00:00.000000Z', + }, + { + id: 2, + commentable_id: 3, + commentable_type: 'page', + parent_id: 1, + local_id: 2, + content_ref: '', + created_by: 1, + updated_by: 1, + created_at: '2026-01-06T00:00:00.000000Z', + updated_at: '2026-01-06T00:00:00.000000Z', + }, +] as const; + +/** Pending imports, shaped like GET /api/imports. */ +const IMPORTS = [ + { + id: 7, + name: 'Stub Import', + size: 2757, + type: 'chapter', + created_by: 1, + created_at: '2026-01-07T00:00:00.000000Z', + updated_at: '2026-01-07T00:00:00.000000Z', + }, +] as const; + +/** Tag names, shaped like GET /api/tags/names. */ +const TAG_NAMES = [ + { + name: 'Category', + values: 2, + usages: 3, + page_count: 1, + chapter_count: 0, + book_count: 2, + shelf_count: 0, + }, +] as const; + +/** Tag values for `Category`, shaped like GET /api/tags/values-for-name. */ +const TAG_VALUES = [ + { + name: 'Category', + value: 'Guide', + usages: 2, + page_count: 0, + chapter_count: 0, + book_count: 2, + shelf_count: 0, + }, + { + name: 'Category', + value: 'Runbook', + usages: 1, + page_count: 1, + chapter_count: 0, + book_count: 0, + shelf_count: 0, + }, +] as const; + +/** + * What every ZIP export answers: a ZIP signature followed by bytes that are not valid UTF-8, + * so text-decoding them anywhere on the way back would be detectable. + */ +const ZIP_BYTES = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0xff, 0xfe, 0x80, 0xc3, 0x28, 0x0a]); + function json(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, @@ -101,6 +196,31 @@ function apiError(code: number, message: string): Response { return json({ error: { code, message } }, code); } +/** Read a request body as JSON, multipart fields, or neither, by its declared type. */ +async function parseBody( + bytes: ArrayBuffer, + contentType: string | undefined, + rawContentType: string | null +): Promise> { + if (contentType === 'application/json' && bytes.byteLength > 0) { + return { body: JSON.parse(new TextDecoder().decode(bytes)), form: undefined }; + } + if (contentType === 'multipart/form-data' && rawContentType) { + const parsed = await new Response(bytes, { + headers: { 'content-type': rawContentType }, + }).formData(); + const form: Record = {}; + for (const [key, value] of parsed.entries()) { + form[key] = + typeof value === 'string' + ? value + : { filename: value.name, bytes: Buffer.from(await value.arrayBuffer()) }; + } + return { body: undefined, form }; + } + return { body: undefined, form: undefined }; +} + /** * Start the stub on an ephemeral loopback port. * @@ -118,18 +238,22 @@ export function startBookStackStub(): BookStackStub { hostname: '127.0.0.1', async fetch(request: Request): Promise { // Read every body, as a real server does, so a reused keep-alive connection stays in sync. - await request.arrayBuffer(); + const bytes = await request.arrayBuffer(); const url = new URL(request.url); const authorization = request.headers.get('authorization') ?? undefined; const path = url.pathname.replace(/^\/api/, ''); - - requests.push({ + const rawContentType = request.headers.get('content-type'); + const contentType = rawContentType?.split(';')[0]?.trim().toLowerCase() || undefined; + const recorded: RecordedRequest = { method: request.method, path, query: Object.fromEntries(url.searchParams), authorization, userAgent: request.headers.get('user-agent') ?? undefined, - }); + contentType, + ...(await parseBody(bytes, contentType, rawContentType)), + }; + requests.push(recorded); // The client sends `Authorization: Token :`. Checking it here is what // makes a tool call prove the outbound credential was actually attached: without @@ -158,6 +282,114 @@ export function startBookStackStub(): BookStackStub { return book ? json(book) : apiError(404, 'Book not found'); } + // BookStack labels every export application/octet-stream; the client derives the type. + const exportMatch = path.match(/^\/(books|chapters|pages)\/(\d+)\/export\/zip$/); + if (request.method === 'GET' && exportMatch) { + return new Response(ZIP_BYTES, { + headers: { + 'content-type': 'application/octet-stream', + 'content-disposition': `attachment; filename="stub-${exportMatch[2]}.zip"`, + }, + }); + } + + if (path === '/comments') { + if (request.method === 'GET') { + return json({ data: COMMENTS, total: COMMENTS.length }); + } + if (request.method === 'POST') { + const input = recorded.body as { + page_id: number; + reply_to?: number; + content_ref?: string; + }; + return json({ + ...COMMENTS[0], + id: 167, + commentable_id: input.page_id, + parent_id: input.reply_to ?? null, + local_id: 3, + content_ref: input.content_ref ?? '', + archived: false, + }); + } + } + + const commentMatch = path.match(/^\/comments\/(\d+)$/); + if (commentMatch) { + const comment = COMMENTS.find((candidate) => candidate.id === Number(commentMatch[1])); + if (!comment) { + return apiError(404, 'Comment not found'); + } + if (request.method === 'GET') { + return json({ + ...comment, + html: '

Stub comment

', + archived: false, + created_by: STUB_USER, + updated_by: STUB_USER, + replies: [], + }); + } + if (request.method === 'PUT') { + const input = recorded.body as { archived?: boolean }; + return json({ ...comment, archived: input.archived ?? false }); + } + if (request.method === 'DELETE') { + return new Response(null, { status: 204 }); + } + } + + if (path === '/imports') { + if (request.method === 'GET') { + return json({ data: IMPORTS, total: IMPORTS.length }); + } + // BookStack reads the ZIP only from a multipart `file` part. + if (request.method === 'POST') { + const file = recorded.form?.file; + if (typeof file !== 'object') { + return json({ error: { code: 422, message: 'The file field is required.' } }, 422); + } + return json({ + ...IMPORTS[0], + size: file.bytes.length, + path: 'uploads/files/imports/stub.zip', + }); + } + } + + const importMatch = path.match(/^\/imports\/(\d+)$/); + if (importMatch) { + const pending = IMPORTS.find((candidate) => candidate.id === Number(importMatch[1])); + if (!pending) { + return apiError(404, 'Import not found'); + } + if (request.method === 'GET') { + return json({ + ...pending, + path: 'uploads/files/imports/stub.zip', + details: { name: pending.name }, + }); + } + if (request.method === 'POST') { + return json({ id: 9, book_id: 1, name: pending.name, slug: 'stub-import', priority: 1 }); + } + if (request.method === 'DELETE') { + return new Response(null, { status: 204 }); + } + } + + if (request.method === 'GET' && path === '/tags/names') { + return json({ data: TAG_NAMES, total: TAG_NAMES.length }); + } + + if (request.method === 'GET' && path === '/tags/values-for-name') { + if (!url.searchParams.get('name')) { + return json({ error: { code: 422, message: 'The name field is required.' } }, 422); + } + return json({ data: TAG_VALUES, total: TAG_VALUES.length }); + } + // Anything else is out of scope on purpose - see the file header. return apiError(404, `The stub does not implement ${request.method} ${url.pathname}`); }, @@ -177,3 +409,4 @@ export function startBookStackStub(): BookStackStub { /** The fixture data the stub serves, for tests that assert on what came back. */ export const STUB_BOOKS = BOOKS; export const STUB_SYSTEM_INFO = SYSTEM_INFO; +export const STUB_ZIP_BYTES = ZIP_BYTES; diff --git a/tests/transport/tools.test.ts b/tests/transport/tools.test.ts index ec4547c..d41b7bb 100644 --- a/tests/transport/tools.test.ts +++ b/tests/transport/tools.test.ts @@ -19,7 +19,13 @@ import type { AddressInfo } from 'node:net'; import Ajv from 'ajv'; import { type Config, ConfigManager } from '../../src/config/manager'; import { createHttpApp } from '../../src/server'; -import { type BookStackStub, STUB_BOOKS, startBookStackStub } from './stub-bookstack'; +import { + type BookStackStub, + type RecordedRequest, + STUB_BOOKS, + STUB_ZIP_BYTES, + startBookStackStub, +} from './stub-bookstack'; const TEST_AUTH_TOKEN = 'tools-inbound-secret-0123456789'; const BODY_LIMIT_BYTES = 1024 * 1024; @@ -51,6 +57,11 @@ const EXPECTED_TOOLS = [ 'bookstack_chapters_list', 'bookstack_chapters_read', 'bookstack_chapters_update', + 'bookstack_comments_create', + 'bookstack_comments_delete', + 'bookstack_comments_list', + 'bookstack_comments_read', + 'bookstack_comments_update', 'bookstack_error_guides', 'bookstack_help', 'bookstack_images_create', @@ -58,6 +69,11 @@ const EXPECTED_TOOLS = [ 'bookstack_images_list', 'bookstack_images_read', 'bookstack_images_update', + 'bookstack_imports_create', + 'bookstack_imports_delete', + 'bookstack_imports_list', + 'bookstack_imports_read', + 'bookstack_imports_run', 'bookstack_pages_append', 'bookstack_pages_create', 'bookstack_pages_delete', @@ -85,6 +101,8 @@ const EXPECTED_TOOLS = [ 'bookstack_shelves_read', 'bookstack_shelves_update', 'bookstack_system_info', + 'bookstack_tags_list_names', + 'bookstack_tags_list_values', 'bookstack_tool_categories', 'bookstack_usage_examples', 'bookstack_users_create', @@ -183,6 +201,15 @@ function toolError(reply: JsonRpcReply): { message: string; data: Record; @@ -378,9 +405,9 @@ describe('tools/list over HTTP', () => { expect(status).toBe(200); const names = (reply.result?.tools ?? []).map((tool) => tool.name).sort(); - // Count first: a bare length mismatch reports far more clearly than a 59-entry diff. + // Count first: a bare length mismatch reports far more clearly than a 71-entry diff. expect(names).toHaveLength(EXPECTED_TOOLS.length); - expect(names).toHaveLength(59); + expect(names).toHaveLength(71); expect(names).toEqual([...EXPECTED_TOOLS]); }); @@ -680,6 +707,13 @@ describe('published JSON Schema agrees with runtime validation on scalar constra return `${prefix}${chars(length - prefix.length)}`; }; + /** A content_ref in the format BookStack keeps, exactly `length` characters long. */ + const commentRefOfLength = (length: number): string => { + const prefix = 'bkmrk-'; + const suffix = ':1:3-14'; + return `${prefix}${chars(length - prefix.length - suffix.length)}${suffix}`; + }; + interface ScalarCase { tool: string; rows: readonly AgreementRow[]; @@ -1020,6 +1054,136 @@ describe('published JSON Schema agrees with runtime validation on scalar constra }, ], }, + { + tool: 'bookstack_comments_create', + rows: [ + { + label: 'control: a comment', + input: { page_id: 1, html: '

Looks good

' }, + accepted: true, + }, + { label: 'empty html', input: { page_id: 1, html: '' }, accepted: false }, + { label: 'whitespace-only html', input: { page_id: 1, html: ' \n\t' }, accepted: false }, + { + label: 'control: a content_ref in the format BookStack keeps', + input: { page_id: 1, html: '

x

', content_ref: commentRefOfLength(64) }, + accepted: true, + }, + { + label: 'content_ref BookStack would silently store as empty', + input: { page_id: 1, html: '

x

', content_ref: 'page-title:3-14' }, + accepted: false, + }, + { + label: 'content_ref at the 255-character maximum', + input: { page_id: 1, html: '

x

', content_ref: commentRefOfLength(255) }, + accepted: true, + }, + { + label: 'content_ref one character over the maximum', + input: { page_id: 1, html: '

x

', content_ref: commentRefOfLength(256) }, + accepted: false, + }, + ], + }, + { + tool: 'bookstack_comments_update', + rows: [ + { label: 'control: archive', input: { id: 1, archived: true }, accepted: true }, + { label: 'control: new html', input: { id: 1, html: '

Edited

' }, accepted: true }, + { label: 'control: unarchive', input: { id: 1, archived: false }, accepted: true }, + { label: 'id alone - nothing to change', input: { id: 1 }, accepted: false }, + { label: 'empty html', input: { id: 1, html: '' }, accepted: false }, + { label: 'whitespace-only html', input: { id: 1, html: ' ' }, accepted: false }, + { label: 'archived as a string', input: { id: 1, archived: 'true' }, accepted: false }, + ], + }, + { + tool: 'bookstack_comments_list', + rows: [ + { label: 'control: count at the 500 maximum', input: { count: 500 }, accepted: true }, + { label: 'count one over the maximum', input: { count: 501 }, accepted: false }, + { label: 'a sort field BookStack ignores', input: { sort: 'html' }, accepted: false }, + { + label: 'commentable_type other than page', + input: { filter: { commentable_type: 'book' } }, + accepted: false, + }, + ], + }, + { + // Exactly one source, and never an empty one. + tool: 'bookstack_imports_create', + rows: [ + { label: 'control: base64 content', input: { file: 'UEsDBA==' }, accepted: true }, + { label: 'neither source', input: {}, accepted: false }, + { label: 'empty base64 file', input: { file: '' }, accepted: false }, + { label: 'empty file_path', input: { file_path: '' }, accepted: false }, + { + label: 'both sources', + input: { file: 'UEsDBA==', file_path: '/srv/a.zip' }, + accepted: false, + }, + ], + }, + { + // parent_type and parent_id together or not at all. + tool: 'bookstack_imports_run', + rows: [ + { label: 'control: a book import, no parent', input: { id: 7 }, accepted: true }, + { + label: 'control: a chapter import into a book', + input: { id: 7, parent_type: 'book', parent_id: 1 }, + accepted: true, + }, + { label: 'parent_type alone', input: { id: 7, parent_type: 'book' }, accepted: false }, + { label: 'parent_id alone', input: { id: 7, parent_id: 1 }, accepted: false }, + { + label: 'a page as parent', + input: { id: 7, parent_type: 'page', parent_id: 1 }, + accepted: false, + }, + ], + }, + { + tool: 'bookstack_imports_list', + rows: [ + { label: 'control: filter by type', input: { filter: { type: 'book' } }, accepted: true }, + { + label: 'a type imports never have', + input: { filter: { type: 'shelf' } }, + accepted: false, + }, + { label: 'negative size', input: { filter: { size: -1 } }, accepted: false }, + ], + }, + { + tool: 'bookstack_tags_list_names', + rows: [ + { label: 'control: by usage', input: { sort: '-usages' }, accepted: true }, + { label: 'count one over the maximum', input: { count: 501 }, accepted: false }, + { + label: 'a value filter, which BookStack ignores on names', + input: { filter: { value: 'Guide' } }, + accepted: false, + }, + ], + }, + { + // `name` is `['required', 'string']` upstream, judged after trimming. + tool: 'bookstack_tags_list_values', + rows: [ + { label: 'control: a tag name', input: { name: 'Category' }, accepted: true }, + { label: 'missing name', input: {}, accepted: false }, + { label: 'empty name', input: { name: '' }, accepted: false }, + { label: 'whitespace-only name', input: { name: ' ' }, accepted: false }, + { + label: 'a name filter, which BookStack ignores on values', + input: { name: 'Category', filter: { name: 'Type' } }, + accepted: false, + }, + ], + }, { // `query` is `['required']` upstream, and Laravel's `required` is judged after the // TrimStrings middleware - so a query of spaces is MISSING, not short. The matrix @@ -1346,7 +1510,10 @@ describe('tools/call over HTTP', () => { const { message, data } = toolError(reply); expect(message).toContain('BOOKSTACK_UPLOAD_ROOT is not set'); - expect(data).toEqual({ type: 'internal_error' }); + expect(data).toEqual({ + type: 'validation_error', + validation: [{ field: 'file_path', message }], + }); expect(JSON.stringify(reply)).not.toContain('upload-probe-marker'); expect(JSON.stringify(reply)).not.toContain('client.ts'); expect(stub.requests).toHaveLength(0); @@ -1393,3 +1560,288 @@ describe('tools/call over HTTP', () => { expect(stub.requests).toHaveLength(0); }); }); + +/** + * Comments, imports, tags and ZIP export, from `tools/call` to the request BookStack receives. + * + * Each case asserts the method, path, query and body (or multipart fields) on the wire, since + * a handler that maps an argument to the wrong place still answers 200 from a lenient server. + */ +describe('comments, imports, tags and ZIP export over HTTP', () => { + /** Call a tool that must succeed, and return its decoded payload. */ + async function callOk( + url: string, + name: string, + args: Record + ): Promise { + const { status, reply } = await rpc(url, 'tools/call', { name, arguments: args }); + expect(status).toBe(200); + expect(reply.error).toBeUndefined(); + expect(reply.result?.isError ?? false).toBe(false); + return JSON.parse(reply.result?.content?.[0]?.text as string); + } + + /** The single request the call made, reduced to what BookStack acts on. */ + function onlyRequest(): Pick< + RecordedRequest, + 'method' | 'path' | 'query' | 'contentType' | 'body' | 'form' + > { + expect(stub.requests).toHaveLength(1); + const { method, path, query, contentType, body, form } = stub.requests[0] as RecordedRequest; + return { method, path, query, contentType, body, form }; + } + + it('lists comments with every list parameter on the query string', async () => { + const url = await startApp(); + + const payload = (await callOk(url, 'bookstack_comments_list', { + count: 5, + offset: 1, + sort: '-created_at', + filter: { commentable_id: 3, parent_id: 1 }, + })) as { total: number }; + + expect(payload.total).toBe(2); + expect(onlyRequest()).toMatchObject({ + method: 'GET', + path: '/comments', + query: { + count: '5', + offset: '1', + sort: '-created_at', + 'filter[commentable_id]': '3', + 'filter[parent_id]': '1', + }, + body: undefined, + form: undefined, + }); + }); + + it('creates a reply as a JSON body carrying exactly the given fields', async () => { + const url = await startApp(); + const args = { + page_id: 3, + html: '

Done.

', + reply_to: 1, + content_ref: 'bkmrk-page-title:7341676876991010:3-14', + }; + + const payload = (await callOk(url, 'bookstack_comments_create', args)) as { + parent_id: number; + }; + + expect(payload.parent_id).toBe(1); + expect(onlyRequest()).toEqual({ + method: 'POST', + path: '/comments', + query: {}, + contentType: 'application/json', + body: args, + form: undefined, + }); + }); + + it('reads a comment by its global id', async () => { + const url = await startApp(); + + const payload = (await callOk(url, 'bookstack_comments_read', { id: 1 })) as { html: string }; + + expect(payload.html).toBe('

Stub comment

'); + expect(onlyRequest()).toMatchObject({ method: 'GET', path: '/comments/1', query: {} }); + }); + + it('updates a comment with a PUT whose body omits the id', async () => { + const url = await startApp(); + + await callOk(url, 'bookstack_comments_update', { id: 1, archived: true }); + + expect(onlyRequest()).toEqual({ + method: 'PUT', + path: '/comments/1', + query: {}, + contentType: 'application/json', + body: { archived: true }, + form: undefined, + }); + }); + + it('deletes a comment', async () => { + const url = await startApp(); + + const payload = await callOk(url, 'bookstack_comments_delete', { id: 2 }); + + expect(payload).toEqual({ success: true, message: 'Comment 2 deleted successfully' }); + expect(onlyRequest()).toMatchObject({ method: 'DELETE', path: '/comments/2', body: undefined }); + }); + + it('lists imports with filters on the query string', async () => { + const url = await startApp(); + + await callOk(url, 'bookstack_imports_list', { filter: { type: 'chapter', created_by: 1 } }); + + expect(onlyRequest()).toMatchObject({ + method: 'GET', + path: '/imports', + query: { + count: '20', + offset: '0', + sort: 'id', + 'filter[type]': 'chapter', + 'filter[created_by]': '1', + }, + }); + }); + + it('uploads a base64 import as the raw bytes of a multipart `file` part', async () => { + const url = await startApp(); + const zip = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0xff, 0x10]); + + const payload = (await callOk(url, 'bookstack_imports_create', { + file: zip.toString('base64'), + })) as { size: number }; + + // The stub reports the size of the part it received. + expect(payload.size).toBe(zip.length); + const sent = onlyRequest(); + expect(sent).toMatchObject({ + method: 'POST', + path: '/imports', + query: {}, + contentType: 'multipart/form-data', + body: undefined, + }); + // `file` alone: no `file_path` and no `_method` override travel with it. + expect(Object.keys(sent.form ?? {})).toEqual(['file']); + const file = sent.form?.file; + if (typeof file !== 'object') { + throw new Error('Expected `file` to arrive as a file part'); + } + expect(file.bytes.equals(zip)).toBe(true); + }); + + it('refuses an import file_path without BOOKSTACK_UPLOAD_ROOT, contacting nothing', async () => { + const savedRoot = process.env.BOOKSTACK_UPLOAD_ROOT; + delete process.env.BOOKSTACK_UPLOAD_ROOT; + try { + const url = await startApp(); + + const { reply } = await rpc(url, 'tools/call', { + name: 'bookstack_imports_create', + arguments: { file_path: '/etc/import-probe-marker.zip' }, + }); + + const { message } = toolError(reply); + expect(message).toContain('BOOKSTACK_UPLOAD_ROOT is not set'); + expect(JSON.stringify(reply)).not.toContain('import-probe-marker'); + expect(stub.requests).toHaveLength(0); + } finally { + if (savedRoot !== undefined) { + process.env.BOOKSTACK_UPLOAD_ROOT = savedRoot; + } + } + }); + + it('reads an import by id', async () => { + const url = await startApp(); + + const payload = (await callOk(url, 'bookstack_imports_read', { id: 7 })) as { + details: unknown; + }; + + expect(payload.details).toEqual({ name: 'Stub Import' }); + expect(onlyRequest()).toMatchObject({ method: 'GET', path: '/imports/7', query: {} }); + }); + + it('runs an import into a parent with a JSON body of the parent fields only', async () => { + const url = await startApp(); + + await callOk(url, 'bookstack_imports_run', { id: 7, parent_type: 'book', parent_id: 1 }); + + expect(onlyRequest()).toEqual({ + method: 'POST', + path: '/imports/7', + query: {}, + contentType: 'application/json', + body: { parent_type: 'book', parent_id: 1 }, + form: undefined, + }); + }); + + it('runs a book import with an empty body', async () => { + const url = await startApp(); + + await callOk(url, 'bookstack_imports_run', { id: 7 }); + + expect(onlyRequest()).toMatchObject({ method: 'POST', path: '/imports/7', body: {} }); + }); + + it('deletes an import', async () => { + const url = await startApp(); + + const payload = await callOk(url, 'bookstack_imports_delete', { id: 7 }); + + expect(payload).toEqual({ success: true, message: 'Import 7 deleted successfully' }); + expect(onlyRequest()).toMatchObject({ method: 'DELETE', path: '/imports/7', body: undefined }); + }); + + it('lists tag names with a name filter', async () => { + const url = await startApp(); + + const payload = (await callOk(url, 'bookstack_tags_list_names', { + filter: { name: 'Category' }, + })) as { data: Array<{ name: string }> }; + + expect(payload.data.map((tag) => tag.name)).toEqual(['Category']); + expect(onlyRequest()).toMatchObject({ + method: 'GET', + path: '/tags/names', + query: { count: '20', offset: '0', sort: 'name', 'filter[name]': 'Category' }, + body: undefined, + form: undefined, + }); + }); + + it('lists tag values with the tag name as a query parameter', async () => { + const url = await startApp(); + + await callOk(url, 'bookstack_tags_list_values', { + name: 'Category', + sort: '-usages', + filter: { value: 'Guide' }, + }); + + expect(onlyRequest()).toMatchObject({ + method: 'GET', + path: '/tags/values-for-name', + query: { + name: 'Category', + count: '20', + offset: '0', + sort: '-usages', + 'filter[value]': 'Guide', + }, + body: undefined, + }); + }); + + for (const [tool, resource] of [ + ['bookstack_books_export', 'books'], + ['bookstack_chapters_export', 'chapters'], + ['bookstack_pages_export', 'pages'], + ] as const) { + it(`returns a ${resource} ZIP export as base64 of the exact bytes, typed application/zip`, async () => { + const url = await startApp(); + + const payload = (await callOk(url, tool, { id: 4, format: 'zip' })) as ExportPayload; + + expect(onlyRequest()).toMatchObject({ method: 'GET', path: `/${resource}/4/export/zip` }); + expect(payload.encoding).toBe('base64'); + // The stub labels it application/octet-stream, as BookStack does. + expect(payload.mime_type).toBe('application/zip'); + expect(payload.filename).toBe('stub-4.zip'); + expect(payload.byte_length).toBe(STUB_ZIP_BYTES.length); + // Bytes that are not valid UTF-8 survive only if nothing text-decoded them. + expect(Buffer.from(payload.content, 'base64').equals(STUB_ZIP_BYTES)).toBe(true); + }); + } +}); diff --git a/tests/unit/comments.test.ts b/tests/unit/comments.test.ts new file mode 100644 index 0000000..d8f64b0 --- /dev/null +++ b/tests/unit/comments.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, type Mock, mock } from 'bun:test'; +import type { BookStackClient } from '../../src/api/client'; +import { CommentTools } from '../../src/tools/comments'; +import type { MCPTool } from '../../src/types'; +import type { Logger } from '../../src/utils/logger'; +import { ValidationHandler } from '../../src/validation/validator'; + +/** See tests/unit/books.test.ts: bun:test has no `jest.Mocked`. */ +type MockedMethods = { + [P in K]: T[P] extends (...args: infer A) => infer R ? Mock<(...args: A) => R> : never; +}; + +type MockClient = MockedMethods< + BookStackClient, + 'listComments' | 'createComment' | 'getComment' | 'updateComment' | 'deleteComment' +>; + +const silentLogger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +} as unknown as Logger; + +describe('CommentTools', () => { + let client: MockClient; + let tools: MCPTool[]; + + beforeEach(() => { + client = { + listComments: mock(), + createComment: mock(), + getComment: mock(), + updateComment: mock(), + deleteComment: mock(), + }; + // The real strict validator: schema defaults are part of what reaches the client. + tools = new CommentTools( + client as unknown as BookStackClient, + new ValidationHandler({ enabled: true, strictMode: true }), + silentLogger + ).getTools(); + }); + + const call = (name: string, params: unknown): Promise => { + const tool = tools.find((candidate) => candidate.name === name); + if (!tool) { + throw new Error(`Expected tool ${name} to be registered`); + } + return tool.handler(params); + }; + + it('registers the five comment tools', () => { + expect(tools.map((tool) => tool.name)).toEqual([ + 'bookstack_comments_list', + 'bookstack_comments_create', + 'bookstack_comments_read', + 'bookstack_comments_update', + 'bookstack_comments_delete', + ]); + }); + + it('lists with the schema defaults and passes filters through', async () => { + const listing = { data: [], total: 0 }; + client.listComments.mockResolvedValue(listing); + + const result = await call('bookstack_comments_list', { + filter: { commentable_id: 42, parent_id: 1 }, + }); + + expect(client.listComments).toHaveBeenCalledWith({ + count: 20, + offset: 0, + sort: 'id', + filter: { commentable_id: 42, parent_id: 1 }, + }); + expect(result).toBe(listing); + }); + + it('creates a reply anchored to page text', async () => { + const input = { + page_id: 42, + html: '

Done.

', + reply_to: 1, + content_ref: 'bkmrk-page-title:7341676876991010:3-14', + }; + + await call('bookstack_comments_create', input); + + expect(client.createComment).toHaveBeenCalledWith(input); + }); + + it('refuses a content_ref BookStack would silently store as empty', async () => { + await expect( + call('bookstack_comments_create', { page_id: 42, html: '

x

', content_ref: 'title' }) + ).rejects.toThrow(); + expect(client.createComment).not.toHaveBeenCalled(); + }); + + it('refuses a blank comment body', async () => { + await expect(call('bookstack_comments_create', { page_id: 42, html: ' ' })).rejects.toThrow(); + expect(client.createComment).not.toHaveBeenCalled(); + }); + + it('reads a comment by its global id', async () => { + await call('bookstack_comments_read', { id: 22 }); + + expect(client.getComment).toHaveBeenCalledWith(22); + }); + + it('splits the id from the changes on update', async () => { + await call('bookstack_comments_update', { id: 167, html: '

Edited

', archived: true }); + + expect(client.updateComment).toHaveBeenCalledWith(167, { + html: '

Edited

', + archived: true, + }); + }); + + it('refuses an update with nothing to change', async () => { + await expect(call('bookstack_comments_update', { id: 167 })).rejects.toThrow( + /html, archived, or both/ + ); + expect(client.updateComment).not.toHaveBeenCalled(); + }); + + it('accepts an update carrying only archived: false', async () => { + await call('bookstack_comments_update', { id: 167, archived: false }); + + expect(client.updateComment).toHaveBeenCalledWith(167, { archived: false }); + }); + + it('publishes that an update needs html or archived', () => { + const update = tools.find((tool) => tool.name === 'bookstack_comments_update'); + + expect(update?.inputSchema.anyOf).toEqual([{ required: ['html'] }, { required: ['archived'] }]); + }); + + it('deletes a comment and reports success', async () => { + client.deleteComment.mockResolvedValue(undefined); + + const result = await call('bookstack_comments_delete', { id: 167 }); + + expect(client.deleteComment).toHaveBeenCalledWith(167); + expect(result).toEqual({ success: true, message: 'Comment 167 deleted successfully' }); + }); +}); diff --git a/tests/unit/error-handler.test.ts b/tests/unit/error-handler.test.ts index 8688cbb..62503b9 100644 --- a/tests/unit/error-handler.test.ts +++ b/tests/unit/error-handler.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from 'bun:test'; import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; -import { ErrorHandler } from '../../src/utils/errors'; +import { ErrorHandler, UploadRefusedError } from '../../src/utils/errors'; import type { Logger } from '../../src/utils/logger'; import { PageContentError, PageStaleError } from '../../src/utils/page-content'; @@ -85,6 +85,20 @@ describe('ErrorHandler tool error results', () => { expect(data).toEqual({ type: 'concurrent_modification', matched_sections: ['Setup'] }); }); + it('reports a refused file_path as a validation error on file_path', () => { + const refusal = new UploadRefusedError("'file_path' must name a readable file."); + + const handled = handler.handleError(refusal); + const { message, data } = readToolError(handler.toToolErrorResult(refusal)); + + expect(handled.code).toBe(ErrorCode.InvalidParams); + expect(message).toBe("'file_path' must name a readable file."); + expect(data).toEqual({ + type: 'validation_error', + validation: [{ field: 'file_path', message: "'file_path' must name a readable file." }], + }); + }); + it('drops the JSON-RPC code prefix from the message', () => { const result = handler.toToolErrorResult( new McpError(ErrorCode.InvalidParams, 'Validation failed', { type: 'validation_error' }) diff --git a/tests/unit/id-schema-contract.test.ts b/tests/unit/id-schema-contract.test.ts index f78b050..1e087e2 100644 --- a/tests/unit/id-schema-contract.test.ts +++ b/tests/unit/id-schema-contract.test.ts @@ -118,6 +118,21 @@ const RULES: Record = { 'bookstack_chapters_update.id': 'entity-id', 'bookstack_chapters_update.priority': 'unbounded', + // --- Comments --- + // `parent_id`, `local_id` and `reply_to` are page-scoped numbers that BookStack starts at 1. + 'bookstack_comments_create.page_id': 'entity-id', + 'bookstack_comments_create.reply_to': 'entity-id', + 'bookstack_comments_delete.id': 'entity-id', + 'bookstack_comments_list.count': 'positive-count', + 'bookstack_comments_list.filter.commentable_id': 'entity-id', + 'bookstack_comments_list.filter.created_by': 'entity-id', + 'bookstack_comments_list.filter.local_id': 'entity-id', + 'bookstack_comments_list.filter.parent_id': 'entity-id', + 'bookstack_comments_list.filter.updated_by': 'entity-id', + 'bookstack_comments_list.offset': 'non-negative', + 'bookstack_comments_read.id': 'entity-id', + 'bookstack_comments_update.id': 'entity-id', + // --- Images --- 'bookstack_images_create.uploaded_to': 'entity-id', 'bookstack_images_delete.id': 'entity-id', @@ -127,6 +142,16 @@ const RULES: Record = { 'bookstack_images_read.id': 'entity-id', 'bookstack_images_update.id': 'entity-id', + // --- Imports --- + 'bookstack_imports_delete.id': 'entity-id', + 'bookstack_imports_list.count': 'positive-count', + 'bookstack_imports_list.filter.created_by': 'entity-id', + 'bookstack_imports_list.filter.size': 'non-negative', + 'bookstack_imports_list.offset': 'non-negative', + 'bookstack_imports_read.id': 'entity-id', + 'bookstack_imports_run.id': 'entity-id', + 'bookstack_imports_run.parent_id': 'entity-id', + // --- Pages --- 'bookstack_pages_append.id': 'entity-id', 'bookstack_pages_create.book_id': 'entity-id', @@ -189,6 +214,12 @@ const RULES: Record = { 'bookstack_shelves_update.books[]': 'entity-id', 'bookstack_shelves_update.id': 'entity-id', + // --- Tags --- + 'bookstack_tags_list_names.count': 'positive-count', + 'bookstack_tags_list_names.offset': 'non-negative', + 'bookstack_tags_list_values.count': 'positive-count', + 'bookstack_tags_list_values.offset': 'non-negative', + // --- Users --- 'bookstack_users_create.roles[]': 'entity-id', 'bookstack_users_delete.id': 'entity-id', @@ -231,11 +262,24 @@ const TOOL_BASES: Record> = { bookstack_chapters_list: { count: 20, offset: 0, filter: { book_id: 1, created_by: 1 } }, bookstack_chapters_read: { id: 1 }, bookstack_chapters_update: { id: 1, book_id: 1, priority: 1, default_template_id: 1 }, + bookstack_comments_create: { page_id: 1, html: '

x

', reply_to: 1 }, + bookstack_comments_delete: { id: 1 }, + bookstack_comments_list: { + count: 20, + offset: 0, + filter: { commentable_id: 1, parent_id: 1, local_id: 1, created_by: 1, updated_by: 1 }, + }, + bookstack_comments_read: { id: 1 }, + bookstack_comments_update: { id: 1, archived: true }, bookstack_images_create: { uploaded_to: 1, name: 'Probe', image: 'aGk=', type: 'gallery' }, bookstack_images_delete: { id: 1 }, bookstack_images_list: { count: 20, offset: 0, filter: { uploaded_to: 1 } }, bookstack_images_read: { id: 1 }, bookstack_images_update: { id: 1, name: 'Probe' }, + bookstack_imports_delete: { id: 1 }, + bookstack_imports_list: { count: 20, offset: 0, filter: { size: 1, created_by: 1 } }, + bookstack_imports_read: { id: 1 }, + bookstack_imports_run: { id: 1, parent_type: 'book', parent_id: 1 }, // The partial-edit tools reach the client on their FIRST call, `getPage`, before they can // apply anything - which is what this harness measures. So a base only has to be // well-formed enough to get past validation; the recording client answers `{}` and the edit @@ -279,6 +323,8 @@ const TOOL_BASES: Record> = { bookstack_shelves_list: { count: 20, offset: 0, filter: { created_by: 1 } }, bookstack_shelves_read: { id: 1 }, bookstack_shelves_update: { id: 1, books: [1] }, + bookstack_tags_list_names: { count: 20, offset: 0 }, + bookstack_tags_list_values: { name: 'Probe', count: 20, offset: 0 }, bookstack_users_create: { name: 'Probe', email: 'probe@example.com', roles: [1] }, // A DIFFERENT heir from `id`: equal ids are refused outright, so a base naming itself would // be rejected for that reason rather than for the value under probe. diff --git a/tests/unit/imports.test.ts b/tests/unit/imports.test.ts new file mode 100644 index 0000000..a2c7f6a --- /dev/null +++ b/tests/unit/imports.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, type Mock, mock } from 'bun:test'; +import type { BookStackClient } from '../../src/api/client'; +import { ImportTools } from '../../src/tools/imports'; +import type { MCPTool } from '../../src/types'; +import type { Logger } from '../../src/utils/logger'; +import { ValidationHandler } from '../../src/validation/validator'; + +/** See tests/unit/books.test.ts: bun:test has no `jest.Mocked`. */ +type MockedMethods = { + [P in K]: T[P] extends (...args: infer A) => infer R ? Mock<(...args: A) => R> : never; +}; + +type MockClient = MockedMethods< + BookStackClient, + 'listImports' | 'createImport' | 'getImport' | 'runImport' | 'deleteImport' +>; + +const silentLogger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +} as unknown as Logger; + +describe('ImportTools', () => { + let client: MockClient; + let tools: MCPTool[]; + + beforeEach(() => { + client = { + listImports: mock(), + createImport: mock(), + getImport: mock(), + runImport: mock(), + deleteImport: mock(), + }; + tools = new ImportTools( + client as unknown as BookStackClient, + new ValidationHandler({ enabled: true, strictMode: true }), + silentLogger + ).getTools(); + }); + + const call = (name: string, params: unknown): Promise => { + const tool = tools.find((candidate) => candidate.name === name); + if (!tool) { + throw new Error(`Expected tool ${name} to be registered`); + } + return tool.handler(params); + }; + + it('registers the five import tools', () => { + expect(tools.map((tool) => tool.name)).toEqual([ + 'bookstack_imports_list', + 'bookstack_imports_create', + 'bookstack_imports_read', + 'bookstack_imports_run', + 'bookstack_imports_delete', + ]); + }); + + it('lists with the schema defaults and passes filters through', async () => { + await call('bookstack_imports_list', { sort: '-created_at', filter: { type: 'book' } }); + + expect(client.listImports).toHaveBeenCalledWith({ + count: 20, + offset: 0, + sort: '-created_at', + filter: { type: 'book' }, + }); + }); + + it('hands base64 content to the client as `file`', async () => { + await call('bookstack_imports_create', { file: 'UEsDBA==' }); + + expect(client.createImport).toHaveBeenCalledWith({ file: 'UEsDBA==' }); + }); + + it('hands a server path to the client as `file_path`, for it to guard', async () => { + await call('bookstack_imports_create', { file_path: '/srv/uploads/handbook.zip' }); + + expect(client.createImport).toHaveBeenCalledWith({ file_path: '/srv/uploads/handbook.zip' }); + }); + + it('refuses neither or both sources before the client is reached', async () => { + await expect(call('bookstack_imports_create', {})).rejects.toThrow(); + await expect( + call('bookstack_imports_create', { file: 'UEsDBA==', file_path: '/srv/a.zip' }) + ).rejects.toThrow(); + expect(client.createImport).not.toHaveBeenCalled(); + }); + + it('reads an import by id', async () => { + await call('bookstack_imports_read', { id: 31 }); + + expect(client.getImport).toHaveBeenCalledWith(31); + }); + + it('runs into a parent, splitting the id from the body', async () => { + await call('bookstack_imports_run', { id: 31, parent_type: 'book', parent_id: 28 }); + + expect(client.runImport).toHaveBeenCalledWith(31, { parent_type: 'book', parent_id: 28 }); + }); + + it('runs a book import with no parent', async () => { + await call('bookstack_imports_run', { id: 25 }); + + expect(client.runImport).toHaveBeenCalledWith(25, {}); + }); + + it('refuses half a parent', async () => { + await expect(call('bookstack_imports_run', { id: 31, parent_type: 'book' })).rejects.toThrow(); + await expect(call('bookstack_imports_run', { id: 31, parent_id: 28 })).rejects.toThrow(); + expect(client.runImport).not.toHaveBeenCalled(); + }); + + it('deletes an import and reports success', async () => { + client.deleteImport.mockResolvedValue(undefined); + + const result = await call('bookstack_imports_delete', { id: 31 }); + + expect(client.deleteImport).toHaveBeenCalledWith(31); + expect(result).toEqual({ success: true, message: 'Import 31 deleted successfully' }); + }); +}); diff --git a/tests/unit/server-info.test.ts b/tests/unit/server-info.test.ts index 92d8aa8..b877d9e 100644 --- a/tests/unit/server-info.test.ts +++ b/tests/unit/server-info.test.ts @@ -3,6 +3,7 @@ import { ConfigManager } from '../../src/config/manager'; import { ServerInfoTools } from '../../src/tools/server-info'; import type { MCPResource, MCPServerInfo, MCPTool } from '../../src/types'; import { Logger } from '../../src/utils/logger'; +import { buildTools, createRecordingClient, requireTool } from '../helpers/strict-tools'; const PINNED_ENV = ['BOOKSTACK_BASE_URL', 'BOOKSTACK_API_TOKEN'] as const; const savedEnv = new Map(PINNED_ENV.map((key) => [key, process.env[key]])); @@ -40,3 +41,31 @@ describe('bookstack_server_info authentication', () => { ]); }); }); + +describe('bookstack_tool_categories', () => { + it('files every registered tool under exactly one category, as its own `category` says', async () => { + const tools = buildTools(createRecordingClient().client); + const { categories } = (await requireTool(tools, 'bookstack_tool_categories').handler({})) as { + categories: Array<{ name: string; tools: string[] }>; + }; + + const listed = categories.flatMap((category) => category.tools); + expect([...listed].sort()).toEqual([...tools.keys()].sort()); + for (const category of categories) { + for (const name of category.tools) { + const declared = requireTool(tools, name).category; + if (declared !== undefined) { + expect(declared, name).toBe(category.name); + } + } + } + }); + + it('offers exactly the category names it lists, in the same order', async () => { + const tools = buildTools(createRecordingClient().client); + const tool = requireTool(tools, 'bookstack_tool_categories'); + const { categories } = (await tool.handler({})) as { categories: Array<{ name: string }> }; + + expect(tool.inputSchema.properties?.category?.enum).toEqual(categories.map((c) => c.name)); + }); +}); diff --git a/tests/unit/tags.test.ts b/tests/unit/tags.test.ts new file mode 100644 index 0000000..61ac0e3 --- /dev/null +++ b/tests/unit/tags.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, type Mock, mock } from 'bun:test'; +import type { BookStackClient } from '../../src/api/client'; +import { TagTools } from '../../src/tools/tags'; +import type { MCPTool } from '../../src/types'; +import type { Logger } from '../../src/utils/logger'; +import { ValidationHandler } from '../../src/validation/validator'; + +/** See tests/unit/books.test.ts: bun:test has no `jest.Mocked`. */ +type MockedMethods = { + [P in K]: T[P] extends (...args: infer A) => infer R ? Mock<(...args: A) => R> : never; +}; + +type MockClient = MockedMethods; + +const silentLogger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +} as unknown as Logger; + +describe('TagTools', () => { + let client: MockClient; + let tools: MCPTool[]; + + beforeEach(() => { + client = { listTagNames: mock(), listTagValues: mock() }; + tools = new TagTools( + client as unknown as BookStackClient, + new ValidationHandler({ enabled: true, strictMode: true }), + silentLogger + ).getTools(); + }); + + const call = (name: string, params: unknown): Promise => { + const tool = tools.find((candidate) => candidate.name === name); + if (!tool) { + throw new Error(`Expected tool ${name} to be registered`); + } + return tool.handler(params); + }; + + it('registers the two tag tools', () => { + expect(tools.map((tool) => tool.name)).toEqual([ + 'bookstack_tags_list_names', + 'bookstack_tags_list_values', + ]); + }); + + it('lists names with the schema defaults', async () => { + const listing = { data: [], total: 0 }; + client.listTagNames.mockResolvedValue(listing); + + const result = await call('bookstack_tags_list_names', {}); + + expect(client.listTagNames).toHaveBeenCalledWith({ count: 20, offset: 0, sort: 'name' }); + expect(result).toBe(listing); + }); + + it('lists the values of one name, filter included', async () => { + await call('bookstack_tags_list_values', { + name: 'Category', + sort: '-usages', + filter: { value: 'Guide' }, + }); + + expect(client.listTagValues).toHaveBeenCalledWith({ + name: 'Category', + count: 20, + offset: 0, + sort: '-usages', + filter: { value: 'Guide' }, + }); + }); + + it('refuses a missing or blank name, which BookStack requires', async () => { + await expect(call('bookstack_tags_list_values', {})).rejects.toThrow(); + await expect(call('bookstack_tags_list_values', { name: ' ' })).rejects.toThrow(); + expect(client.listTagValues).not.toHaveBeenCalled(); + }); + + it('refuses a filter field BookStack does not filter on', async () => { + await expect( + call('bookstack_tags_list_names', { filter: { value: 'Guide' } }) + ).rejects.toThrow(); + expect(client.listTagNames).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/upload-guard.test.ts b/tests/unit/upload-guard.test.ts index 23abc39..eab806f 100644 --- a/tests/unit/upload-guard.test.ts +++ b/tests/unit/upload-guard.test.ts @@ -8,10 +8,12 @@ */ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'bun:test'; -import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, realpath, rm, symlink, truncate, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { readGuardedUploadFile } from '../../src/api/client'; +import { UPLOAD_MAX_BYTES } from '../../src/types'; +import { UploadRefusedError } from '../../src/utils/errors'; const GUARD_ENV = ['MCP_TRANSPORT', 'BOOKSTACK_UPLOAD_ROOT'] as const; const savedEnv = new Map(); @@ -20,6 +22,10 @@ let base: string; let uploadRoot: string; let outside: string; let inside: string; +let subdir: string; +let fifo: string | undefined; +let oversize: string; +let atLimit: string; function setGuardEnv(transport: string, root: string | undefined): void { process.env.MCP_TRANSPORT = transport; @@ -52,6 +58,17 @@ beforeAll(async () => { outside = join(base, 'outside-secret.txt'); await writeFile(outside, 'outside the root'); await symlink(outside, join(uploadRoot, 'link-out.txt')); + subdir = join(uploadRoot, 'subdir'); + await mkdir(subdir); + // Sparse files: the size is real to stat() without writing 50 MB to disk. + oversize = join(uploadRoot, 'oversize.bin'); + await writeFile(oversize, ''); + await truncate(oversize, UPLOAD_MAX_BYTES + 1); + atLimit = join(uploadRoot, 'at-limit.bin'); + await writeFile(atLimit, ''); + await truncate(atLimit, UPLOAD_MAX_BYTES); + const made = Bun.spawnSync(['mkfifo', join(uploadRoot, 'pipe')]); + fifo = made.exitCode === 0 ? join(uploadRoot, 'pipe') : undefined; }); afterEach(() => { @@ -96,11 +113,15 @@ describe('file_path upload guard', () => { join(uploadRoot, 'missing.txt'), outside, uploadRoot, + subdir, + oversize, + ...(fifo ? [fifo] : []), ]) { - const { message } = await refusal(candidate); + const error = await refusal(candidate); // Neither the resolved path nor the root: a refusal is not a map of the server. - expect(message).not.toContain(base); - messages.add(message); + expect(error.message).not.toContain(base); + expect(error).toBeInstanceOf(UploadRefusedError); + messages.add(error.message); } // Identical wording, so a refusal cannot reveal whether a path outside the root exists. @@ -108,6 +129,31 @@ describe('file_path upload guard', () => { }); } + it('refuses a directory, a FIFO and a file over 50000 KB inside the root', async () => { + setGuardEnv('stdio', uploadRoot); + + // A FIFO would block readFile() forever; it must be refused, not read. + for (const candidate of [subdir, oversize, ...(fifo ? [fifo] : [])]) { + const error = await refusal(candidate); + expect(error).toBeInstanceOf(UploadRefusedError); + } + expect(fifo).toBeDefined(); + }); + + it('reads a file of exactly 50000 KB', async () => { + setGuardEnv('stdio', uploadRoot); + + expect((await readGuardedUploadFile(atLimit)).byteLength).toBe(UPLOAD_MAX_BYTES); + }); + + it('refuses with UploadRefusedError when the root is unset or unusable', async () => { + setGuardEnv('http', undefined); + expect(await refusal(inside)).toBeInstanceOf(UploadRefusedError); + + setGuardEnv('http', join(base, 'no-such-root')); + expect(await refusal(inside)).toBeInstanceOf(UploadRefusedError); + }); + it('does not name a BOOKSTACK_UPLOAD_ROOT that cannot be resolved', async () => { const missingRoot = join(base, 'no-such-root'); setGuardEnv('http', missingRoot); diff --git a/tests/unit/upload-limits.test.ts b/tests/unit/upload-limits.test.ts new file mode 100644 index 0000000..ae1269e --- /dev/null +++ b/tests/unit/upload-limits.test.ts @@ -0,0 +1,99 @@ +/** + * The 50000 KB upload ceiling on every inline base64 field, held against the runtime. + * + * The tools promised "at most 50000 KB" in prose while neither the published schema nor + * the validator enforced it, so an oversized payload was decoded and sent to BookStack. + * Both halves are probed at the boundary: the published JSON Schema through ajv, as a + * client reads it, and the real strict handler over a recording client. + */ + +import { describe, expect, it } from 'bun:test'; +import Ajv from 'ajv'; +import { UPLOAD_MAX_BASE64_LENGTH, UPLOAD_MAX_BYTES } from '../../src/types'; +import { buildTools, createRecordingClient, requireTool } from '../helpers/strict-tools'; + +/** Every base64 upload field, with a request that is valid apart from that field. */ +const UPLOAD_FIELDS: Record }> = { + bookstack_attachments_create: { field: 'file', base: { uploaded_to: 1, name: 'Probe' } }, + bookstack_attachments_update: { field: 'file', base: { id: 1 } }, + bookstack_images_create: { field: 'image', base: { uploaded_to: 1 } }, + bookstack_images_update: { field: 'image', base: { id: 1 } }, + bookstack_imports_create: { field: 'file', base: {} }, +}; + +const ajv = new Ajv({ allErrors: true, strictSchema: false, validateFormats: false }); + +const atLimit = 'A'.repeat(UPLOAD_MAX_BASE64_LENGTH); +const overLimit = `${atLimit}A`; + +/** Did the strict handler let this input through to the client? */ +async function runtimeAccepts(toolName: string, input: Record): Promise { + const { calls, client } = createRecordingClient(); + try { + await requireTool(buildTools(client), toolName).handler(input); + } catch { + // Whether the client was reached is the signal, not the throw. + } + return calls.length > 0; +} + +function schemaAccepts(toolName: string, input: Record): boolean { + const { client } = createRecordingClient(); + return ajv.validate(requireTool(buildTools(client), toolName).inputSchema, input) === true; +} + +describe('the base64 upload ceiling', () => { + it('is the base64 length of 50000 KB', () => { + expect(UPLOAD_MAX_BYTES).toBe(50_000 * 1024); + expect(UPLOAD_MAX_BASE64_LENGTH).toBe(68_266_668); + // The padded base64 of exactly 50000 KB fits; one more 3-byte group does not. + expect(Buffer.alloc(UPLOAD_MAX_BYTES).toString('base64')).toHaveLength( + UPLOAD_MAX_BASE64_LENGTH + ); + expect(Buffer.alloc(UPLOAD_MAX_BYTES + 2).toString('base64').length).toBeGreaterThan( + UPLOAD_MAX_BASE64_LENGTH + ); + }); + + it('is published as maxLength on every base64 upload field', () => { + const { client } = createRecordingClient(); + const published: Record = {}; + for (const tool of buildTools(client).values()) { + for (const name of ['file', 'image']) { + const property = tool.inputSchema.properties[name]; + if (property?.type === 'string') { + published[`${tool.name}.${name}`] = property.maxLength; + } + } + } + + const expected = Object.fromEntries( + Object.entries(UPLOAD_FIELDS).map(([tool, { field }]) => [ + `${tool}.${field}`, + UPLOAD_MAX_BASE64_LENGTH, + ]) + ); + expect(published).toEqual(expected); + }); + + it('is enforced by the schema and the runtime alike, before BookStack', async () => { + const outcomes: Record = {}; + const intended: Record = {}; + + for (const [tool, { field, base }] of Object.entries(UPLOAD_FIELDS)) { + for (const [label, value, accepted] of [ + ['at the limit', atLimit, true], + ['one character over', overLimit, false], + ] as const) { + const input = { ...base, [field]: value }; + outcomes[`${tool}: ${label}`] = { + schema: schemaAccepts(tool, input), + runtime: await runtimeAccepts(tool, input), + }; + intended[`${tool}: ${label}`] = { schema: accepted, runtime: accepted }; + } + } + + expect(outcomes).toEqual(intended); + }, 30_000); +}); From b3b716020f52efa937a66e6e3f0026d5f6a7e32b Mon Sep 17 00:00:00 2001 From: gusocegodk Date: Wed, 7 Oct 2026 10:16:14 +0200 Subject: [PATCH 2/2] docs(readme): describe the fork and its per-user OAuth usage --- README.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 769ece0..61a208a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,75 @@ This server supports two transport modes: **Streamable HTTP** (default) and **St > âš ī¸ **Looking for the HTTP endpoint?** The MCP endpoint is `POST /message` — not `/`. > See [Transports](#-transports) and [HTTP endpoints](#http-endpoints) below. +## 🍴 About this fork + +This is a fork of [pnocera/bookstack-mcp-server](https://github.com/pnocera/bookstack-mcp-server), +maintained at [cego/bookstack-mcp-server](https://github.com/cego/bookstack-mcp-server). Its +primary use is **per-user OAuth**: each person signs in to their AI assistant with your OIDC +provider, and BookStack sees *their* account, permissions and audit trail instead of one shared +API token. + +What the fork adds on top of upstream: + +- **Per-user OAuth** (`MCP_AUTH_MODE=oauth`): the HTTP transport is an OAuth resource server per + the MCP authorization spec. Access tokens must be issued for this server, and each one is + exchanged (RFC 8693) for a BookStack token, so the caller's own token is never forwarded. + See [Per-user OAuth](#per-user-oauth) for the full flow and requirements. +- **More tools**: comments, ZIP imports, tags and ZIP export. +- **Hardening** from a full security review: allowlisted upstream overrides, guarded and + size-limited uploads, linear-time page parsing, and no stack traces or paths in errors. +- **Container images** instead of npm releases. + +### Run it with OAuth + +Images are published as `ghcr.io/cego/bookstack-mcp-server:-r`, where +`` is the upstream version the fork builds on and `` counts the fork's +releases of it. See [Releases](https://github.com/cego/bookstack-mcp-server/releases) for the +current tag and digest, and pin the digest when you deploy. + +```bash +# bookstack-mcp.env - keep it out of version control; it holds the client secret +BOOKSTACK_BASE_URL=https://bookstack.example.com/api +MCP_AUTH_MODE=oauth +MCP_OAUTH_ISSUER=https://idp.example.com +MCP_OAUTH_RESOURCE=https://bookstack-mcp.example.com/message +MCP_OAUTH_CLIENT_ID=bookstack-mcp-server +MCP_OAUTH_CLIENT_SECRET= +BOOKSTACK_OAUTH_AUDIENCE=bookstack-api +``` + +```bash +docker run -d --name bookstack-mcp -p 3000:3000 --env-file bookstack-mcp.env \ + ghcr.io/cego/bookstack-mcp-server:-r +``` + +Before it works end to end you need: + +1. **A BookStack that accepts OIDC access tokens on its API** (`OIDC_API_ACCESS_TOKENS=true`, + from [BookStack PR 6237](https://codeberg.org/bookstack/bookstack/pulls/6237)), with + `OIDC_API_AUDIENCE` set to `BOOKSTACK_OAUTH_AUDIENCE` and `OIDC_API_ALLOWED_CLIENTS` set to + `MCP_OAUTH_CLIENT_ID`. Users must have logged in to BookStack once. +2. **Two OAuth clients at your provider**: one that users sign in with, whose access tokens carry + `MCP_OAUTH_RESOURCE` and `MCP_OAUTH_CLIENT_ID` (but not BookStack's audience) in `aud`, and + the confidential exchange client `MCP_OAUTH_CLIENT_ID`, held only by this server, allowed to + exchange those tokens for `BOOKSTACK_OAUTH_AUDIENCE`. +3. **The server reachable at `MCP_OAUTH_RESOURCE`**, including + `/.well-known/oauth-protected-resource/message` on the same origin. + +Then connect your assistant with the **sign-in** client, never the exchange client: + +```bash +# Claude Code +claude mcp add --transport http --client-id --client-secret \ + --callback-port bookstack https://bookstack-mcp.example.com/message +``` + +For a Claude custom connector, use `https://bookstack-mcp.example.com/message` as the URL and +the sign-in client's ID (and secret, if it is confidential) under *Advanced settings*. + +The rest of this README is upstream's documentation, kept current for this fork. The +shared-token and stdio modes below still work. + ## ✨ What You Get - **BookStack Integration** - Access your books, pages, chapters, and content @@ -20,6 +89,10 @@ This server supports two transport modes: **Streamable HTTP** (default) and **St ## 🚀 Quick Start +> â„šī¸ `bunx`/`bun add` below install **upstream's** npm release, which lacks this fork's +> changes. For the fork, use the container image ([Run it with OAuth](#run-it-with-oauth)) or +> run from a clone (`bun install && bun run start`). + > âš ī¸ **Requires [Bun](https://bun.sh) 1.1.0 or newer. Node.js is not supported.** > This package ships TypeScript source rather than a compiled bundle, and its > executable starts with `#!/usr/bin/env bun` — Bun must be installed on the @@ -519,8 +592,9 @@ This project is part of the BookStack ecosystem! Check out other API-based tools ## 🆘 Support - **📚 Documentation**: Complete guides in the [docs/](docs/) folder -- **🐛 Issues**: [GitHub Issues](https://github.com/pnocera/bookstack-mcp-server/issues) -- **đŸ’Ŧ Discussions**: [GitHub Discussions](https://github.com/pnocera/bookstack-mcp-server/discussions) +- **🐛 Issues with this fork** (OAuth mode, images, fork-only tools): [cego/bookstack-mcp-server issues](https://github.com/cego/bookstack-mcp-server/issues) +- **🐛 Upstream issues**: [pnocera/bookstack-mcp-server issues](https://github.com/pnocera/bookstack-mcp-server/issues) +- **đŸ’Ŧ Upstream discussions**: [GitHub Discussions](https://github.com/pnocera/bookstack-mcp-server/discussions) ---