diff --git a/README.md b/README.md index 62fc49a..187fdf7 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 56 tools and 11 resources 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 59 tools and 11 resources 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. 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 -- **56 MCP Tools & 11 Resources** - CRUD, search and export across the supported endpoint families +- **59 MCP Tools & 11 Resources** - 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 56 tools with the +what callers must present to `POST /message`, which dispatches all 59 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. @@ -171,7 +171,7 @@ with the failing check named: "status": "unhealthy", "checks": [ { "name": "bookstack_connection", "healthy": false, "message": "BookStack API connection" }, - { "name": "tools_loaded", "healthy": true, "message": "56 tools loaded" }, + { "name": "tools_loaded", "healthy": true, "message": "59 tools loaded" }, { "name": "resources_loaded", "healthy": true, "message": "11 resources loaded" } ] } @@ -247,10 +247,10 @@ MCP clients pipe over stdin/stdout. For stdio you also don't need `-p 3000:3000` ## πŸ› οΈ Available Tools -**56 tools across 13 categories:** +**59 tools across 13 categories:** - **πŸ“š Books** (6) - Create, read, update, delete, and export books -- **πŸ“„ Pages** (6) - Manage pages with HTML/Markdown content +- **πŸ“„ 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 - **πŸ” Search** (1) - Search across content types @@ -268,6 +268,67 @@ Not exposed (no tools): comments, imports, tag-name listings, the image-gallery > πŸ“– See the complete [Tools Overview](docs/tools-overview.md) for detailed documentation +## βœ‚οΈ Partial page editing + +The BookStack API has no PATCH for page content β€” `PUT /api/pages/{id}` takes a complete +`html` or `markdown` body. Changing one paragraph of a long page therefore meant reading all +of it, having the model reproduce it verbatim with the change applied, and sending it all +back: the content crosses the model twice, and the whole page rides on it being copied +byte-perfectly. + +Three tools run that read-modify-write cycle inside the server instead, so a caller sends +only the fragment it wants changed: + +| Tool | What it does | +|---|---| +| `bookstack_pages_outline` | Heading structure with offsets and section sizes. No content transferred. | +| `bookstack_pages_read` with `grep` | Matching excerpts from the **stored** source β€” paste one straight into `old_string`. | +| `bookstack_pages_edit` | Literal find-and-replace. `old_string` must match exactly and be unique unless `replace_all` is set. | +| `bookstack_pages_append` | Insert at the end of the page, the end of a named section, or right after a section heading. | + +```jsonc +// 1. What sections exist, and how big are they? +{ "tool": "bookstack_pages_outline", "arguments": { "id": 12 } } + +// 2. Get an exact anchor without loading the page +{ "tool": "bookstack_pages_read", + "arguments": { "id": 12, "grep": "retention period", "context": 300 } } + +// 3. Rehearse: nothing is written +{ "tool": "bookstack_pages_edit", + "arguments": { "id": 12, "dry_run": true, + "edits": [{ "old_string": "retention period of 6 months", + "new_string": "retention period of 24 months" }] } } + +// 4. Apply with a stale-page preflight +{ "tool": "bookstack_pages_edit", + "arguments": { "id": 12, "expected_updated_at": "2026-08-17T09:12:44.000000Z", + "edits": [{ "old_string": "retention period of 6 months", + "new_string": "retention period of 24 months" }] } } +``` + +**Guards.** An ambiguous anchor is refused rather than applied to the wrong place, and the +error carries the first few matches with context. A missing anchor reports the same text found +with different whitespace, which is the usual near-miss. A result smaller than half the +original is refused unless `allow_shrink` is set. After a write the page is re-read and the +change is looked for in normalised text β€” BookStack rewrites stored HTML on save (heading +anchors, injected `id` attributes), so a byte comparison would call every success a failure. +Every write creates a BookStack revision, so an applied edit can be rolled back in the UI. +No response from these tools contains page content. +`expected_updated_at` detects a page changed before this server reads it; BookStack's page +API does not provide an atomic version condition, so it cannot prevent a write that races +after that check. + +**Two invariants**, if you touch this code (`src/utils/page-content.ts`): markdown pages are +patched and written through `markdown`, because writing `html` to one switches the page's +editor type; every other page is patched against `raw_html`, never the rendered `html` β€” +patching the rendered output would write back expanded page-include tags and destroy the +includes permanently. + +`bookstack_pages_update` is unchanged and still replaces the whole content field; these tools +are additive. `bookstack_pages_read` called without any of the new options returns exactly +what it always did. + ## πŸ“š Documentation Find comprehensive guides in the `docs/` folder: diff --git a/docker-compose.yml b/docker-compose.yml index 70da911..30149ef 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 (56 tools, field shapes, + # Pinned, not :latest. The tool contract in this repo (59 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 56 tools and share one admin token, so the + # integration suites drive all 59 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 56 tools (permanent-delete, users, roles, + # /message dispatches all 59 tools (permanent-delete, users, roles, # permissions) using BOOKSTACK_API_TOKEN. Generate with `openssl rand -hex 32`. MCP_AUTH_TOKEN: ${MCP_AUTH_TOKEN:-} SERVER_PORT: "3000" diff --git a/docs/api-reference.md b/docs/api-reference.md index 0d78a68..b2a3888 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -29,7 +29,7 @@ The BookStack MCP Server provides comprehensive access to the BookStack knowledg ### Key Features -- **API Coverage**: 56 tools and 11 resources 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**: 59 tools and 11 resources 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) - **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 @@ -325,9 +325,22 @@ interface CreatePageParams { ```typescript // Tool: bookstack_pages_read interface PageWithContent extends Page { - html: string; // Rendered HTML content - raw_html: string; // Raw HTML as stored - markdown?: string; // Markdown source if available + html: string; // Rendered HTML content, page includes resolved + raw_html: string; // Raw HTML as stored - this is what the edit tools patch + markdown?: string; // Markdown source; empty string for HTML-authored pages +} + +// With any option below set, the response is a narrowed summary instead of the +// full page object. With none set, the complete page object comes back unchanged. +interface ReadPageOptions { + id: number; // Required + grep?: string; // Literal phrase in the STORED source (1-1000 chars); returns excerpts only + case_sensitive?: boolean; // Default false + context?: number; // Context characters per match, 1-2000, default 200 + max_matches?: number; // Excerpts returned, 1-50, default 10 (total is still reported) + offset?: number; // Start of a character window into the stored source + length?: number; // Length of that window + metadata_only?: boolean; // Metadata and total_chars only, no content } ``` @@ -336,6 +349,71 @@ interface PageWithContent extends Page { // Tool: bookstack_pages_update // Same as CreatePageParams but all fields optional except id // Can move pages between books/chapters by changing book_id/chapter_id +// NOTE: replaces the ENTIRE content field. For a partial change use +// bookstack_pages_edit or bookstack_pages_append below. +``` + +#### Edit Page (partial update) +```typescript +// Tool: bookstack_pages_edit +// +// The BookStack API has no PATCH for page content, so the server performs the +// read-modify-write cycle itself: it reads the page, applies the edits to its +// STORED source (raw_html, or markdown for markdown pages) and writes the result +// back. The caller sends only the fragment it wants changed. +interface EditPageParams { + id: number; // Required + edits: Array<{ // Required, at least one; applied in order + old_string: string; // Exact text, whitespace included; must be unique + new_string: string; // Replacement; '' deletes the anchored text + replace_all?: boolean; // Default false; opt-in for a repeated anchor + }>; + dry_run?: boolean; // Report what would change, write nothing + expected_updated_at?: string; // Best-effort stale-page preflight; not an atomic lock + allow_shrink?: boolean; // Permit a result under half the original size +} + +// The response carries no page content: +// { +// page_id, name, slug, book_id, chapter_id, updated_at, revision_count, +// editor, field, chars_before, chars_after, delta, +// edits: [{ index, occurrences_replaced, context }], +// written, verified, unverified_fragment_count, chars_stored +// } +``` + +An anchor that cannot be applied comes back as `InvalidParams` with actionable +detail rather than a bare failure: `found_with_different_whitespace` when the text +exists but the whitespace differs, `first_occurrences` when it matched more than +once, `chars_before`/`chars_after` when the shrink guard fired. A page that changed +before this server read it comes back as `InvalidRequest` with +`type: 'concurrent_modification'`. + +#### Append to Page +```typescript +// Tool: bookstack_pages_append +interface AppendPageParams { + id: number; // Required + content: string; // Required, in the page's own format + position?: 'start' | 'end'; // Default 'end'; 'start' means after the heading + section?: string; // Heading text; omit to target the whole page + separator?: string; // Default '\n\n' for markdown, '\n' otherwise + dry_run?: boolean; + expected_updated_at?: string; +} +``` + +An unknown `section` fails with `available_sections` listing the real heading names. + +#### Outline Page +```typescript +// Tool: bookstack_pages_outline +// Heading structure only - no content is transferred. +// { +// page_id, name, slug, book_id, chapter_id, updated_at, revision_count, +// editor, field, total_chars, heading_count, +// headings: [{ level, text, offset, length }] // length = section size +// } ``` #### Delete Page @@ -1230,4 +1308,4 @@ for (const book of books.data) { - Validation is **strict by default** (`VALIDATION_STRICT_MODE=true`): invalid params are rejected at the boundary rather than forwarded to BookStack. Set it to `false` to log a warning and forward them instead - Pagination `count` is capped at 500 (100 for `bookstack_search`); above the cap the call is **rejected, not clamped** -For additional help, use the `bookstack_help` tool or consult the error guides with `bookstack_error_guides`. \ No newline at end of file +For additional help, use the `bookstack_help` tool or consult the error guides with `bookstack_error_guides`. diff --git a/docs/examples-and-workflows.md b/docs/examples-and-workflows.md index 35f5d25..f80edfb 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": "56 tools loaded" }, + { "name": "tools_loaded", "healthy": true, "message": "59 tools loaded" }, { "name": "resources_loaded", "healthy": true, "message": "11 resources loaded" } ] } diff --git a/docs/integration-testing.md b/docs/integration-testing.md index 13efa6a..74d5d04 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 β€” -56 tools, field shapes, error codes β€” was verified against v26.05.2, and a floating tag +59 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 0bbc383..d9143b8 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 -- **56 MCP Tools** across 13 categories, covering the supported subset of the BookStack API +- **59 MCP Tools** across 13 categories, covering the supported subset of the BookStack API - **11 Resources** for dynamic content retrieval - **Rate Limiting** with configurable limits - **Comprehensive Validation** using Zod schemas @@ -678,7 +678,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": "56 tools loaded"}, + {"name": "tools_loaded", "healthy": true, "message": "59 tools loaded"}, {"name": "resources_loaded", "healthy": true, "message": "11 resources loaded"} ] } diff --git a/docs/tools-overview.md b/docs/tools-overview.md index 870494a..4879e58 100644 --- a/docs/tools-overview.md +++ b/docs/tools-overview.md @@ -1,16 +1,16 @@ # BookStack MCP Server Tools Overview -## All 56 Tools Across 13 Categories +## All 59 Tools Across 13 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 **56 tools** (and **11 resources**) 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 **59 tools** (and **11 resources**) 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 categories below are the ones returned by `bookstack_tool_categories`, and the -per-category counts add up to the 56 tools the server registers at boot (it logs -`Registered 56 tools` / `Registered 11 resources` on startup): +per-category counts add up to the 59 tools the server registers at boot (it logs +`Registered 59 tools` / `Registered 11 resources` on startup): | Section | Category | Tools | |---------|----------|-------| @@ -49,7 +49,7 @@ per-category counts add up to the 56 tools the server registers at boot (it logs - Use filtering to find specific topic areas - Combine with pagination for large book collections -### 2. Pages Management (6 tools) +### 2. Pages Management (9 tools) **Category**: `pages` **Purpose**: Manage individual pages - the core content units @@ -57,8 +57,11 @@ per-category counts add up to the 56 tools the server registers at boot (it logs |-----------|-------------|----------------| | `bookstack_pages_list` | List pages with filtering by book/chapter | count, offset, sort, filter (book_id, chapter_id, draft, template) | | `bookstack_pages_create` | Create new page with HTML or Markdown content | name (required), book_id/chapter_id, html/markdown, tags, priority | -| `bookstack_pages_read` | Get page details with full content | id (required) | -| `bookstack_pages_update` | Update page content and move between containers | id (required), name, html/markdown, book_id, chapter_id, tags, priority | +| `bookstack_pages_read` | Get page details with full content, or narrowed excerpts | id (required), grep, case_sensitive, context, max_matches, offset, length, metadata_only | +| `bookstack_pages_update` | Replace page content and move between containers | id (required), name, html/markdown, book_id, chapter_id, tags, priority | +| `bookstack_pages_edit` | Change parts of a page by literal find-and-replace | id (required), edits (required), dry_run, expected_updated_at, allow_shrink | +| `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) | @@ -68,6 +71,42 @@ per-category counts add up to the 56 tools the server registers at boot (it logs - Draft and template pages - Content migration between books/chapters +#### Partial editing + +The BookStack API offers only full replacement β€” `PUT /api/pages/{id}` takes a complete +`html` or `markdown` body, and there is no PATCH. Changing one paragraph of a long page +therefore meant reading all of it, reproducing it verbatim with the change applied, and +sending it all back: the content crosses the model twice, and the whole page rides on it +being copied byte-perfectly. `bookstack_pages_edit`, `bookstack_pages_append` and +`bookstack_pages_outline` run that read-modify-write cycle inside the server, so a caller +sends only the fragment it wants changed. + +Two invariants hold for anything touching page content (`src/utils/page-content.ts`): + +- **Markdown pages** are patched and written through `markdown`. Writing `html` to one + switches the page's editor type, which the API gives no way to undo. +- **Every other page** is patched against `raw_html`, the stored source β€” never against + `html`, the rendered output. Patching the rendered output would write back expanded + page-include tags and destroy the includes permanently. + +The guards, and why each exists: + +| Guard | Behaviour | +|-------|-----------| +| Uniqueness | `old_string` must match exactly once, or the edit is refused. The error carries the first few ambiguous matches with context. `replace_all` is the explicit opt-in. | +| Whitespace diagnostics | When an anchor is not found, the error reports the same text found with different whitespace β€” the most common near-miss β€” so the caller can retry with the real bytes. | +| `dry_run` | Applies the edits in memory and reports what would change. Nothing is sent to BookStack. | +| `expected_updated_at` | Best-effort stale-page preflight against `updated_at`. It catches a page changed before the server reads it; BookStack has no atomic version condition, so it cannot prevent a later racing write. | +| Shrink guard | A result smaller than half the original is refused unless `allow_shrink` is set, so an anchor that accidentally swallows most of the document cannot be applied. | +| Post-write verification | The page is re-read and the written fragments are looked for in normalised text β€” BookStack rewrites stored HTML on save (heading anchors, injected `id` attributes), so a byte comparison would report every success as a failure. A fragment that cannot be found comes back as `verified: false` rather than an error: the write did happen. | + +Every write creates a BookStack revision, so an applied edit can be rolled back in the UI. +No response from these tools contains page content, which is what keeps it out of the model. + +**Recommended flow for a large page**: `bookstack_pages_outline` to see the structure β†’ +`bookstack_pages_read` with `grep` to get an exact anchor β†’ `bookstack_pages_edit` with +`dry_run: true` to confirm it resolves β†’ the same call with `expected_updated_at` to apply it. + ### 3. Chapters Management (6 tools) **Category**: `chapters` **Purpose**: Organize pages within books @@ -540,6 +579,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 56 tools across 13 categories, 11 resources, 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 59 tools across 13 categories, 11 resources, 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. \ No newline at end of file +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/config/manager.ts b/src/config/manager.ts index 018db21..120c840 100644 --- a/src/config/manager.ts +++ b/src/config/manager.ts @@ -123,7 +123,7 @@ export const DEFAULT_HTTP_BODY_LIMIT_BYTES = 70 * 1024 * 1024; // 73,400,320 * Settings that exist only for the HTTP transport. * * Deliberately kept out of `ConfigSchema`: `Config` is handed to the BookStack client, - * the validator and all 56 tools, and is merged per request in the /message handler + * the validator and all 59 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 1c6e287..873bda9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -118,7 +118,7 @@ function splitArgumentNames(tool: MCPTool, args: unknown): { known: string[]; un * through the Model Context Protocol (MCP). * * Features: - * - 56 tools across the supported subset of the BookStack API: books, chapters, pages, + * - 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. @@ -528,7 +528,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 56 tools - including permanent-delete, ' + + 'secret, because POST /message dispatches all 59 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 ' + @@ -780,7 +780,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 56 tools and 11 resources - work an + * requests because constructing one registers all 59 tools and 11 resources - work an * anonymous caller must not be able to trigger per request. */ function healthServer(): BookStackMCPServer { diff --git a/src/tools/pages.ts b/src/tools/pages.ts index be9a1c9..89c248b 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -4,21 +4,67 @@ import { type MCPTool, NONBLANK_PATTERN, type PagesListInput, + type PageWithContent, toPagesListParams, type UpdatePageParams, withClosedSchemas, } from '../types'; import type { Logger } from '../utils/logger'; -import type { ExportRequest, IdRequest, ValidationHandler } from '../validation/validator'; +import { + applyEdits, + assertNoUnexpectedShrink, + buildOutline, + containsNormalized, + grepContent, + insertContent, + normalizeForComparison, + type PageSource, + PageStaleError, + selectSource, + sliceContent, +} from '../utils/page-content'; +import type { + ExportRequest, + IdRequest, + PageAppendRequest, + PageEditRequest, + PageReadRequest, + ValidationHandler, +} from '../validation/validator'; /** The whole `bookstack_pages_update` request: the page to update, plus the changes. */ type UpdatePageRequest = UpdatePageParams & IdRequest; +/** What a post-write read must prove without requiring byte-identical HTML. */ +interface WriteVerification { + mustContain: string[]; + mustNotContain: string[]; +} + /** * Page management tools for BookStack MCP Server * - * Provides 6 tools for complete page lifecycle management: + * Provides 9 tools for complete page lifecycle management: * - List, create, read, update, delete, and export pages + * - Edit, append to, and outline a page WITHOUT resending its whole content + * + * ## Why the partial-edit tools exist + * + * The BookStack API offers only full replacement: `PUT /api/pages/{id}` takes a complete + * `html` or `markdown` body and there is no PATCH. So changing one paragraph of a 40 KB page + * meant reading all of it, having the model reproduce it verbatim with the change applied, + * and sending it all back - which burns the content through the context twice and stakes the + * whole page on the model copying it byte-perfectly. `bookstack_pages_edit`, + * `bookstack_pages_append` and `bookstack_pages_outline` run that read-modify-write cycle + * here instead, so a caller sends only the fragment it wants changed. + * + * Two invariants hold for any code that touches page content (see ../utils/page-content.ts): + * + * - Markdown pages are patched and written through `markdown`. Writing `html` to one + * switches the page's editor type. + * - Every other page is patched against `raw_html`, the stored source - never against + * `html`, the rendered output. Patching the rendered output would write back expanded + * page-include tags and permanently destroy the includes. */ export class PageTools { constructor( @@ -36,6 +82,9 @@ export class PageTools { this.createCreatePageTool(), this.createReadPageTool(), this.createUpdatePageTool(), + this.createEditPageTool(), + this.createAppendPageTool(), + this.createOutlinePageTool(), this.createDeletePageTool(), this.createExportPageTool(), ]); @@ -320,7 +369,7 @@ export class PageTools { return { name: 'bookstack_pages_read', description: - 'Get the full details and content of a page. `html` is always populated (fully rendered, with page includes resolved); `raw_html` is the unrendered stored HTML. `markdown` is only populated for pages last edited with the Markdown editor - it is an empty string for HTML-authored pages, so never treat it as the page content without checking.', + 'Get the full details and content of a page. `html` is always populated (fully rendered, with page includes resolved); `raw_html` is the unrendered stored HTML. `markdown` is only populated for pages last edited with the Markdown editor - it is an empty string for HTML-authored pages, so never treat it as the page content without checking.\n\nFor a large page, prefer the narrowing options over reading the whole thing: `grep` returns only matching excerpts (and is the way to obtain an exact `old_string` anchor for bookstack_pages_edit), `offset`/`length` return a character window, and `metadata_only` returns no content at all. Without any of them the response is the complete page object, unchanged.', inputSchema: { type: 'object', required: ['id'], @@ -330,32 +379,144 @@ export class PageTools { minimum: 1, description: 'The unique ID of the page to read.', }, + grep: { + type: 'string', + minLength: 1, + maxLength: 1000, + description: + 'Literal text to search for in the STORED page source. Returns matching excerpts with surrounding context instead of the whole page. Regex syntax is treated literally, so searching the stored source (not the rendered HTML) makes a returned excerpt usable verbatim as an `old_string` anchor.', + }, + case_sensitive: { + type: 'boolean', + default: false, + description: 'Match `grep` case-sensitively. Default is case-insensitive.', + }, + context: { + type: 'integer', + minimum: 1, + maximum: 2000, + default: 200, + description: + 'Characters of context to include on each side of a `grep` match. Widen this when the excerpt is not unique enough to anchor an edit; below roughly 40 an excerpt is rarely unique enough to use as one. The upper bound is what keeps a grep from returning the whole page.', + }, + max_matches: { + type: 'integer', + minimum: 1, + maximum: 50, + default: 10, + description: + 'Maximum number of `grep` excerpts to return. The response still reports the true total, so a truncated result is visible.', + }, + offset: { + type: 'integer', + minimum: 0, + description: + 'Start of a character window into the stored source. Use with `length` to page through a document too large to read at once.', + }, + length: { + type: 'integer', + minimum: 1, + description: 'Length of the character window that starts at `offset`.', + }, + metadata_only: { + type: 'boolean', + default: false, + description: + 'Return only page metadata and the content size, with no content. Use to check `updated_at` or the page size before deciding how to read it.', + }, }, }, examples: [ { - description: 'Read a page', + description: 'Read a page in full', input: { id: 12 }, expected_output: 'Page object with content fields', use_case: 'Retrieving content for analysis or update', }, + { + description: 'Find an exact anchor for an edit, without loading the whole page', + input: { id: 12, grep: 'retention period', context: 300 }, + expected_output: + 'Matching excerpts with offsets and context; no full page content in the response', + use_case: 'Locating the text to change before calling bookstack_pages_edit', + }, + { + description: 'Check size and modification time before reading', + input: { id: 12, metadata_only: true }, + expected_output: 'Page metadata plus total_chars, no content', + use_case: 'Deciding whether a page needs windowed reading', + }, ], usage_patterns: [ 'Use this to get the "before" state of content when performing updates', 'Useful for answering questions based on specific documentation', + 'On a large page, run `grep` first and pass the returned excerpt to bookstack_pages_edit as `old_string` - that avoids sending the page through the model twice', + 'A call with none of the narrowing options behaves exactly as before and returns the whole page', + ], + related_tools: [ + 'bookstack_books_read', + 'bookstack_search', + 'bookstack_pages_outline', + 'bookstack_pages_edit', ], - related_tools: ['bookstack_books_read', 'bookstack_search'], error_codes: [ { code: 'NOT_FOUND', description: 'Page not found', recovery_suggestion: 'Verify ID', }, + { + code: 'INVALID_PARAMS', + description: '`grep` must be between 1 and 1,000 characters', + recovery_suggestion: 'Use a shorter literal phrase from the page source.', + }, ], handler: async (params: unknown) => { - const { id } = this.validator.validateParams(params, 'id'); + const options = this.validator.validateParams(params, 'pageRead'); + const { id } = options; this.logger.debug('Reading page', { id }); - return await this.client.getPage(id); + + const page = await this.client.getPage(id); + + // Back-compatible fast path: with no narrowing option set, hand back exactly the + // object this tool has always returned. Every branch below is opt-in. + const narrowed = + options.grep !== undefined || + options.metadata_only || + options.offset !== undefined || + options.length !== undefined; + if (!narrowed) { + return page; + } + + const source = selectSource(page); + const base = { + ...this.pageSummary(page), + editor: source.editor, + field: source.writeField, + total_chars: source.source.length, + }; + + if (options.metadata_only) { + return base; + } + + if (options.grep !== undefined) { + const found = grepContent(source.source, options.grep, { + caseInsensitive: !options.case_sensitive, + contextChars: options.context, + maxMatches: options.max_matches, + }); + return { + ...base, + pattern: options.grep, + total_matches: found.total, + truncated: found.truncated, + matches: found.matches, + }; + } + + return { ...base, ...sliceContent(source.source, options.offset ?? 0, options.length) }; }, }; } @@ -472,6 +633,520 @@ export class PageTools { }; } + /** + * Edit page tool - literal find-and-replace against the stored source. + */ + private createEditPageTool(): MCPTool { + return { + name: 'bookstack_pages_edit', + description: + 'Change parts of a page without resending the whole thing. Each edit replaces a literal `old_string` with a `new_string`; the server reads the page, applies the edits to its stored source and writes the result back.\n\n`old_string` must match EXACTLY (whitespace included) and must be unique in the page, unless `replace_all` is set - an ambiguous anchor is refused rather than applied to the wrong place. Get an exact anchor from `bookstack_pages_read` with `grep`. The response never contains the page content, only a summary of what changed.', + category: 'pages', + inputSchema: { + type: 'object', + required: ['id', 'edits'], + properties: { + id: { + type: 'integer', + minimum: 1, + description: 'ID of the page to edit.', + }, + edits: { + type: 'array', + minItems: 1, + description: + 'Edits applied in order, each to the result of the previous one. A later edit can therefore anchor on text an earlier one introduced.', + items: { + type: 'object', + required: ['old_string', 'new_string'], + properties: { + old_string: { + type: 'string', + minLength: 1, + description: + 'Exact text to find in the stored page source, whitespace included. Must be unique unless replace_all is true. If it is not found, the error reports whether the same text exists with different whitespace.', + }, + new_string: { + type: 'string', + description: + 'Replacement text. The empty string deletes the anchored text. Must differ from old_string.', + }, + replace_all: { + type: 'boolean', + default: false, + description: + 'Replace every occurrence instead of requiring a unique match. Use for a rename that legitimately appears many times.', + }, + }, + }, + }, + dry_run: { + type: 'boolean', + default: false, + description: + 'Apply the edits in memory and report what WOULD change, without writing. Nothing is sent to BookStack. Use this to confirm the anchors resolve before touching the page.', + }, + expected_updated_at: { + type: 'string', + minLength: 1, + description: + "Best-effort stale preflight: the page's `updated_at` as seen when the anchors were read. The write is refused if it has already changed when the server reads it. BookStack's API has no atomic conditional update, so this cannot prevent a change made between that read and the subsequent write.", + }, + allow_shrink: { + type: 'boolean', + default: false, + description: + 'Permit a result smaller than half the original. Off by default, so an anchor that accidentally swallows most of the document is refused rather than applied.', + }, + }, + }, + examples: [ + { + description: 'Check that an anchor resolves, without writing', + input: { + id: 12, + edits: [ + { + old_string: 'retention period of 6 months', + new_string: 'retention period of 24 months', + }, + ], + dry_run: true, + }, + expected_output: + 'Summary with chars_before/chars_after and the matched context; written: false', + use_case: 'Verifying an edit before applying it', + }, + { + description: 'Apply the edit with a stale preflight', + input: { + id: 12, + edits: [ + { + old_string: 'retention period of 6 months', + new_string: 'retention period of 24 months', + }, + ], + expected_updated_at: '2026-08-17T09:12:44.000000Z', + }, + expected_output: 'Summary with written: true, verified: true and the new revision_count', + use_case: 'Correcting one sentence in a long policy page', + }, + ], + usage_patterns: [ + 'Locate the text first: bookstack_pages_read with `grep` returns excerpts you can paste straight into old_string', + 'Run with dry_run: true first on anything non-trivial - it costs no write and proves the anchors resolve', + 'Pass expected_updated_at from the read that produced your anchors to catch a page that was already stale when this server read it; BookStack cannot make this a race-free lock', + 'BookStack keeps a revision per write, so an applied edit can be rolled back in the UI', + 'Prefer this over bookstack_pages_update for partial changes: update replaces the entire content field', + ], + related_tools: [ + 'bookstack_pages_read', + 'bookstack_pages_outline', + 'bookstack_pages_append', + 'bookstack_pages_update', + ], + error_codes: [ + { + code: 'INVALID_PARAMS', + description: + 'An anchor was not found, or matched more than once without replace_all, or the result would shrink the page by more than half', + recovery_suggestion: + 'Read the error details: they carry the same text found with different whitespace, or the first few ambiguous matches. Widen the anchor, set replace_all, or set allow_shrink.', + }, + { + code: 'INVALID_REQUEST', + description: 'The page changed since expected_updated_at', + recovery_suggestion: 'Re-read the page, rebuild the anchors and retry', + }, + { + code: 'NOT_FOUND', + description: 'Page not found', + recovery_suggestion: 'Verify ID', + }, + ], + handler: async (params: unknown) => { + const options = this.validator.validateParams(params, 'pageEdit'); + const { id } = options; + + const page = await this.client.getPage(id); + this.assertNotStale(page, options.expected_updated_at); + + const source = selectSource(page); + const { result, applied } = applyEdits(source.source, options.edits); + assertNoUnexpectedShrink(source.source, result, options.allow_shrink); + + const summary = { + ...this.pageSummary(page), + editor: source.editor, + field: source.writeField, + chars_before: source.source.length, + chars_after: result.length, + delta: result.length - source.source.length, + edits: applied, + }; + + if (options.dry_run) { + return { + ...summary, + dry_run: true, + written: false, + hint: 'Re-run without dry_run to apply these edits.', + }; + } + + this.logger.info('Editing page', { id, edit_count: options.edits.length }); + + return { + ...summary, + ...(await this.writeAndVerify(page, source, result, { + mustContain: options.edits + // A later edit may have replaced text an earlier edit introduced. Require only + // fragments that remain in the final source, otherwise a correct chained write + // would be reported as unverified. + .filter((edit) => edit.new_string.length > 0 && result.includes(edit.new_string)) + .map((edit) => edit.new_string), + mustNotContain: options.edits + .filter((edit) => { + if (result.includes(edit.old_string)) { + // A later edit deliberately restored this anchor. + return false; + } + // When an edit contributes no final fragment to mustContain, the old anchor's + // absence is the remaining evidence that it landed. This covers deletions and + // replacements subsequently overwritten by a later edit. + if (edit.new_string.length === 0 || !result.includes(edit.new_string)) { + return true; + } + // A replacement already present in the original source cannot prove that this + // edit landed, including markup-only fragments omitted by text normalisation. + const normalized = normalizeForComparison(edit.new_string, source.writeField); + if (normalized.length === 0) { + const collapseWhitespace = (value: string) => value.replace(/\s+/g, ' ').trim(); + return collapseWhitespace(source.source).includes( + collapseWhitespace(edit.new_string) + ); + } + return containsNormalized(source.source, edit.new_string, source.writeField); + }) + .map((edit) => edit.old_string), + })), + }; + }, + }; + } + + /** + * Append page tool - insert a fragment at a page or section boundary. + */ + private createAppendPageTool(): MCPTool { + return { + name: 'bookstack_pages_append', + description: + 'Add content to a page without resending the existing content. Appends to the end of the page by default, or to the end of a named section, or directly after a section heading with `position: "start"`.\n\nSection names come from `bookstack_pages_outline`; matching is case-insensitive and falls back to a substring match. The response never contains the page content.', + category: 'pages', + inputSchema: { + type: 'object', + required: ['id', 'content'], + properties: { + id: { + type: 'integer', + minimum: 1, + description: 'ID of the page to append to.', + }, + content: { + type: 'string', + minLength: 1, + description: + "Content to insert, in the page's own format - Markdown for a page authored in the Markdown editor, HTML otherwise. Check `editor` via bookstack_pages_outline if unsure.", + }, + position: { + type: 'string', + enum: ['start', 'end'], + default: 'end', + description: + 'Where to insert within the target range. "end" appends at the end of the page or section; "start" inserts directly AFTER the section heading, which is how you add a lead paragraph to a section.', + }, + section: { + type: 'string', + minLength: 1, + description: + 'Heading text of the section to insert into. Omit to target the whole page. If no heading matches, the error lists the available section names.', + }, + separator: { + type: 'string', + description: + 'Text placed between the existing content and the insertion. Defaults to a blank line for Markdown pages and a single newline otherwise. Set to an empty string to join without a break.', + }, + dry_run: { + type: 'boolean', + default: false, + description: 'Report what would change without writing anything.', + }, + expected_updated_at: { + type: 'string', + minLength: 1, + description: + "Best-effort stale preflight: reject if the page is already changed when the server reads it. This is not an atomic lock because BookStack's update API accepts no version precondition.", + }, + }, + }, + examples: [ + { + description: 'Append a paragraph to the end of a page', + input: { id: 12, content: '

Reviewed in August 2026.

' }, + expected_output: 'Summary with written: true and the character delta', + use_case: 'Adding a note without touching existing content', + }, + { + description: 'Add a row to a specific section', + input: { + id: 12, + section: 'Change log', + content: '

2026-08-17: retention extended.

', + }, + expected_output: 'Summary naming the section that was appended to', + use_case: 'Maintaining a log section in a long document', + }, + ], + usage_patterns: [ + 'Call bookstack_pages_outline first to get exact section names and see which editor the page uses', + 'Match the page format: HTML for a wysiwyg page, Markdown for a markdown page - mixing them produces visible markup', + 'Use position: "start" to introduce a section, "end" to add to it', + 'This never rewrites existing content, so it is the safe choice for adding to a page you have not read', + ], + related_tools: ['bookstack_pages_outline', 'bookstack_pages_edit', 'bookstack_pages_read'], + error_codes: [ + { + code: 'INVALID_PARAMS', + description: 'The named section does not exist', + recovery_suggestion: + 'The error details list the available section names; use one of those or omit `section` to append to the page', + }, + { + code: 'INVALID_REQUEST', + description: 'The page changed since expected_updated_at', + recovery_suggestion: 'Re-read the page and retry', + }, + { + code: 'NOT_FOUND', + description: 'Page not found', + recovery_suggestion: 'Verify ID', + }, + ], + handler: async (params: unknown) => { + const options = this.validator.validateParams(params, 'pageAppend'); + const { id } = options; + + const page = await this.client.getPage(id); + this.assertNotStale(page, options.expected_updated_at); + + const source = selectSource(page); + const result = insertContent(source.source, options.content, { + position: options.position, + ...(options.section !== undefined ? { section: options.section } : {}), + ...(options.separator !== undefined ? { separator: options.separator } : {}), + writeField: source.writeField, + }); + + const summary = { + ...this.pageSummary(page), + editor: source.editor, + field: source.writeField, + position: options.position, + section: options.section ?? null, + chars_before: source.source.length, + chars_after: result.length, + delta: result.length - source.source.length, + }; + + if (options.dry_run) { + return { + ...summary, + dry_run: true, + written: false, + hint: 'Re-run without dry_run to apply this insertion.', + }; + } + + this.logger.info('Appending to page', { id, position: options.position }); + + return { + ...summary, + ...(await this.writeAndVerify(page, source, result, { + mustContain: [options.content], + mustNotContain: [], + })), + }; + }, + }; + } + + /** + * Outline page tool - the heading structure, without the content. + */ + private createOutlinePageTool(): MCPTool { + return { + name: 'bookstack_pages_outline', + description: + "Map a page's heading structure without transferring its content. Returns each heading with its level, exact text, character offset and section size, plus which editor the page uses.\n\nThis is the cheap first step for working on a large page: it tells you what sections exist (for `bookstack_pages_append`), how big each one is, and whether the page is HTML or Markdown.", + category: 'pages', + inputSchema: { + type: 'object', + required: ['id'], + properties: { + id: { + type: 'integer', + minimum: 1, + description: 'ID of the page to outline.', + }, + }, + }, + examples: [ + { + description: 'Inspect the structure of a long page', + input: { id: 12 }, + expected_output: + 'editor, field, total_chars, heading_count and a headings array with level/text/offset/length', + use_case: 'Deciding which section to edit or append to', + }, + ], + usage_patterns: [ + 'Run this before bookstack_pages_append to get exact section names', + 'The `length` of a heading is the size of its section, which shows where the content actually sits', + 'Check `editor` before writing: it decides whether content must be HTML or Markdown', + 'A page with no headings returns an empty array - that is not an error', + ], + related_tools: ['bookstack_pages_read', 'bookstack_pages_append', 'bookstack_pages_edit'], + error_codes: [ + { + code: 'NOT_FOUND', + description: 'Page not found', + recovery_suggestion: 'Verify ID', + }, + ], + handler: async (params: unknown) => { + const { id } = this.validator.validateParams(params, 'id'); + this.logger.debug('Outlining page', { id }); + + const page = await this.client.getPage(id); + const source = selectSource(page); + const headings = buildOutline(source.source, source.writeField); + + return { + ...this.pageSummary(page), + editor: source.editor, + field: source.writeField, + total_chars: source.source.length, + heading_count: headings.length, + headings, + }; + }, + }; + } + + /** + * The page facts the partial-edit tools report back. + * + * Carries no page content. These tools exist to keep page content out of the model, so a + * response that echoed it back would undo the saving. `updated_at` is included because it + * is the value a caller passes as the next `expected_updated_at`. + */ + private pageSummary(page: PageWithContent): Record { + return { + page_id: page.id, + name: page.name, + slug: page.slug, + book_id: page.book_id, + chapter_id: page.chapter_id, + updated_at: page.updated_at, + revision_count: page.revision_count, + }; + } + + /** + * Refuse a write if the page had already moved when this server read it. + * + * Compared as strings against what BookStack reported, not as parsed dates: the API's + * microsecond precision survives a round trip, and parsing would introduce a way for two + * different timestamps to compare equal. This remains a preflight, not a lock: BookStack + * accepts an unconditional PUT, so another actor can still write after this comparison. + */ + private assertNotStale(page: PageWithContent, expectedUpdatedAt?: string): void { + if (expectedUpdatedAt === undefined || expectedUpdatedAt === page.updated_at) { + return; + } + + throw new PageStaleError('Page was modified since it was read', { + expected_updated_at: expectedUpdatedAt, + actual_updated_at: page.updated_at, + hint: 'Re-read the page, rebuild your anchors against the new content and retry.', + }); + } + + /** + * Write the patched source back, then read the page again and confirm the change landed. + * + * The re-read is not paranoia about the network - it is about BookStack rewriting what it + * stores. On save it re-generates heading anchors and injects `id` attributes, so the bytes + * that come back are not the bytes that were sent. Verification therefore compares + * NORMALISED text (see containsNormalized), and a fragment that cannot be found is reported + * as `verified: false` rather than thrown: the write did happen, and the caller needs to + * know both facts. + */ + private async writeAndVerify( + page: PageWithContent, + source: PageSource, + result: string, + verification: WriteVerification + ): Promise> { + await this.client.updatePage(page.id, { [source.writeField]: result }); + + const written = await this.client.getPage(page.id); + const writtenSource = selectSource(written); + const missing = verification.mustContain.filter((fragment) => { + const normalized = normalizeForComparison(fragment, writtenSource.writeField); + if (normalized.length === 0) { + // HTML-to-text intentionally removes markup-only fragments (`
`, ``, etc.). + // They still need a structural post-write check, otherwise a stored fragment would be + // reported as missing forever; collapse formatting whitespace but require the literal + // markup to remain present. + const collapseWhitespace = (value: string) => value.replace(/\s+/g, ' ').trim(); + return !collapseWhitespace(writtenSource.source).includes(collapseWhitespace(fragment)); + } + return !containsNormalized(writtenSource.source, fragment, writtenSource.writeField); + }); + // An empty replacement deletes its old anchor. An empty normalised anchor cannot be + // meaningfully searched for, so treat it as unverified rather than claiming success. + const stillPresent = verification.mustNotContain.filter((fragment) => { + const normalized = normalizeForComparison(fragment, writtenSource.writeField); + return ( + normalized.length === 0 || + containsNormalized(writtenSource.source, fragment, writtenSource.writeField) + ); + }); + const unverifiedCount = missing.length + stillPresent.length; + + if (unverifiedCount > 0) { + // Not an error: the page WAS written. But a fragment we cannot find afterwards means + // BookStack transformed it beyond recognition (a sanitiser dropping a tag, say), and + // that is worth an operator's attention. Count only - the fragments are page content. + this.logger.warn('Page write could not be verified', { + page_id: page.id, + unverified_fragment_count: unverifiedCount, + }); + } + + return { + written: true, + verified: unverifiedCount === 0, + unverified_fragment_count: unverifiedCount, + updated_at: written.updated_at, + revision_count: written.revision_count, + chars_stored: writtenSource.source.length, + }; + } + /** * Delete page tool */ diff --git a/src/tools/server-info.ts b/src/tools/server-info.ts index fa32cf7..15136bd 100644 --- a/src/tools/server-info.ts +++ b/src/tools/server-info.ts @@ -294,6 +294,7 @@ export class ServerInfoTools { type: 'string', enum: [ 'create_documentation', + 'edit_part_of_large_page', 'organize_content', 'user_management', 'search_content', @@ -464,18 +465,24 @@ export class ServerInfoTools { }, { name: 'pages', - description: 'Manage individual pages - the core content units', + description: + 'Manage individual pages - the core content units. Includes partial editing, so a change to one paragraph does not require resending the whole page.', tools: [ 'bookstack_pages_list', 'bookstack_pages_create', 'bookstack_pages_read', 'bookstack_pages_update', + 'bookstack_pages_edit', + 'bookstack_pages_append', + 'bookstack_pages_outline', 'bookstack_pages_delete', 'bookstack_pages_export', ], use_cases: [ 'Create articles and documentation', 'Update existing content', + 'Change parts of a large page without rewriting it', + 'Navigate a long page by its heading structure', 'Manage page hierarchy', ], }, @@ -597,7 +604,7 @@ export class ServerInfoTools { use_cases: ['Upload images', 'Manage gallery assets'], }, { - // Without this entry the categories described 51 of the server's 56 tools: + // 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. @@ -747,6 +754,66 @@ export class ServerInfoTools { ], expected_outcome: 'Updated documentation with current and accurate information', }, + { + key: 'edit_part_of_large_page', + title: 'Change a Small Part of a Large Page', + description: + 'Correct one passage in a long page without reading or rewriting the rest. Prefer this over bookstack_pages_update, which replaces the entire content field and therefore requires the model to reproduce the whole page verbatim.', + workflow: [ + { + step: 1, + action: 'Map the page structure', + tool_or_resource: 'bookstack_pages_outline', + parameters: { id: 12 }, + description: + 'See which sections exist, how big each one is, and whether the page is HTML or Markdown - all without transferring content', + }, + { + step: 2, + action: 'Find an exact anchor', + tool_or_resource: 'bookstack_pages_read', + parameters: { id: 12, grep: 'retention period', context: 300 }, + description: + 'Returns only matching excerpts from the STORED source. Copy one verbatim as old_string - that is what makes the anchor match', + }, + { + step: 3, + action: 'Rehearse the edit', + tool_or_resource: 'bookstack_pages_edit', + parameters: { + id: 12, + edits: [ + { + old_string: 'retention period of 6 months', + new_string: 'retention period of 24 months', + }, + ], + dry_run: true, + }, + description: + 'Confirms the anchor resolves and is unique, and reports the size change. Nothing is written', + }, + { + step: 4, + action: 'Apply it with a stale-page preflight', + tool_or_resource: 'bookstack_pages_edit', + parameters: { + id: 12, + edits: [ + { + old_string: 'retention period of 6 months', + new_string: 'retention period of 24 months', + }, + ], + expected_updated_at: '2026-08-17T09:12:44.000000Z', + }, + description: + 'Pass the updated_at from step 2 to catch a page changed before this server reads it. BookStack does not provide an atomic version condition, so this cannot prevent a later racing write.', + }, + ], + expected_outcome: + 'One passage changed, the rest of the page untouched and never sent through the model. BookStack records a revision, so the change can be rolled back in the UI.', + }, { key: 'organize_content', title: 'Reorganize Existing Content', diff --git a/src/types.ts b/src/types.ts index ee0b279..c5fb8b8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1127,6 +1127,13 @@ export interface MCPSchemaNode { maximum?: number; minLength?: number; maxLength?: number; + /** + * Fewest items an array may carry. Paired with a `.min(n)` on the matching zod array, the + * way `minLength` is paired with `z.string().min(n)`: the published schema and the runtime + * rule have to agree, or a client trusts a contract the server does not keep. + */ + minItems?: number; + maxItems?: number; /** * A regular expression the string must match, in JSON Schema's sense: UNANCHORED, so * the pattern must merely be found somewhere in the value unless it anchors itself. diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 3eaa09a..419229d 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -2,6 +2,7 @@ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import type { AxiosError } from 'axios'; import type { ZodError } from 'zod'; import type { Logger } from './logger'; +import { PageContentError, PageStaleError } from './page-content'; /** * Loosely-typed view of the error shapes this handler inspects at runtime. @@ -198,6 +199,31 @@ export class ErrorHandler { return this.handleAxiosError(error as AxiosError); } + // A partial-page edit that could not be applied: the anchor was not found, or was not + // unique, or the named section does not exist. InvalidParams, not InternalError, because + // each case describes the CALLER's input. The `details` object is what makes the error + // actionable: it carries the available section names, the first few ambiguous matches, or + // the same text found with different whitespace, so a model can correct its own call. + // The generic branch below would report an internal fault and discard all of that. + // `error.message`, not `err.message`: inside these branches the narrowed class guarantees + // a string, while the `ErrorLike` view types it as optional. + if (error instanceof PageContentError) { + return new McpError(ErrorCode.InvalidParams, error.message, { + type: 'page_content_error', + ...error.details, + }); + } + + // The page moved under the caller between its read and this write. InvalidRequest, not + // InvalidParams: the arguments were right, the world changed. The distinction is what a + // client branches on - re-read and retry, rather than rewrite the anchor. + if (error instanceof PageStaleError) { + return new McpError(ErrorCode.InvalidRequest, error.message, { + type: 'concurrent_modification', + ...error.details, + }); + } + // Handle validation errors from Zod if (err.name === 'ZodError') { const validationDetails = (error as ZodError).issues.map((issue) => ({ diff --git a/src/utils/page-content.ts b/src/utils/page-content.ts new file mode 100644 index 0000000..40a229b --- /dev/null +++ b/src/utils/page-content.ts @@ -0,0 +1,466 @@ +import type { PageWithContent } from '../types'; + +/** + * Page content helpers for partial page editing + * + * The BookStack API only supports full replacement of page content + * (`PUT /api/pages/{id}` with a complete `html` or `markdown` field). + * These pure functions let the MCP server perform the read-modify-write + * cycle itself, so callers only ever send the changed fragment. + */ + +export type PageWriteField = 'html' | 'markdown'; + +export interface PageSource { + /** Field to send back to the API when writing */ + writeField: PageWriteField; + /** Content to patch against */ + source: string; + /** Editor type reported by BookStack */ + editor: string; +} + +export interface PageEdit { + old_string: string; + new_string: string; + replace_all?: boolean; +} + +export interface AppliedEdit { + index: number; + occurrences_replaced: number; + context: string; +} + +export interface Heading { + level: number; + text: string; + offset: number; + length: number; +} + +export interface GrepMatch { + offset: number; + match: string; + context: string; +} + +/** + * Error carrying actionable detail back to the caller without + * dumping the whole page content into the response. + */ +export class PageContentError extends Error { + constructor( + message: string, + public readonly details?: Record + ) { + super(message); + this.name = 'PageContentError'; + } +} + +/** + * The page changed between the read the caller based its anchor on and this write. + * + * Separate from PageContentError because it maps to a different MCP error code: the caller's + * parameters were fine, the world moved. A client should re-read and retry, not rewrite its + * arguments. See ErrorHandler.handleError(). + */ +export class PageStaleError extends Error { + constructor( + message: string, + public readonly details?: Record + ) { + super(message); + this.name = 'PageStaleError'; + } +} + +const CONTEXT_RADIUS = 120; +const MAX_DIAGNOSTIC_LENGTH = 300; + +/** Escape literal text for a regular expression that must retain literal semantics. */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Decide which field to patch and which one to write back. + * + * Markdown pages must be patched and written as `markdown`, otherwise + * BookStack may switch the page editor type. HTML pages must be patched + * against `raw_html` (the stored source) rather than `html` (the rendered + * output), otherwise page include tags get expanded permanently. + */ +export function selectSource(page: PageWithContent): PageSource { + const editor = page.editor || ''; + const rawHtml = typeof page.raw_html === 'string' ? page.raw_html : ''; + + if (editor === 'markdown' && typeof page.markdown === 'string') { + // Stay on the markdown path even when the page is still empty, otherwise + // the first append to a fresh markdown page would switch its editor type. + // Only an inconsistent page (no markdown but stored HTML) falls back. + if (page.markdown.trim().length > 0 || rawHtml.trim().length === 0) { + return { writeField: 'markdown', source: page.markdown, editor }; + } + } + + const source = rawHtml.length > 0 ? rawHtml : page.html || ''; + + return { writeField: 'html', source, editor }; +} + +/** + * Count literal (non-regex) occurrences of a needle. + */ +export function countOccurrences(haystack: string, needle: string): number { + if (needle.length === 0) { + return 0; + } + return haystack.split(needle).length - 1; +} + +/** + * Build a short excerpt around a position, with ellipses where truncated. + */ +export function contextAround( + text: string, + offset: number, + radius: number = CONTEXT_RADIUS +): string { + const start = Math.max(0, offset - radius); + const end = Math.min(text.length, offset + radius); + return `${start > 0 ? '…' : ''}${text.slice(start, end)}${end < text.length ? '…' : ''}`; +} + +/** + * When an exact match fails, look for the same text with different + * whitespace. HTML stored by BookStack often differs from what a caller + * copied out of a rendered view only by line breaks and indentation. + */ +function findWhitespaceTolerantMatch(source: string, needle: string): string | null { + const trimmed = needle.trim(); + if (trimmed.length === 0) { + return null; + } + + const pattern = trimmed.split(/\s+/).map(escapeRegExp).join('\\s+'); + + const match = new RegExp(pattern).exec(source); + if (!match) { + return null; + } + + return match[0].length > MAX_DIAGNOSTIC_LENGTH + ? `${match[0].slice(0, MAX_DIAGNOSTIC_LENGTH)}…` + : match[0]; +} + +/** + * Apply a list of literal string edits in order. + * + * Each `old_string` must appear exactly once unless `replace_all` is set, + * so an ambiguous anchor can never silently patch the wrong place. + */ +export function applyEdits( + source: string, + edits: PageEdit[] +): { result: string; applied: AppliedEdit[] } { + if (!Array.isArray(edits) || edits.length === 0) { + throw new PageContentError('At least one edit is required'); + } + + let current = source; + const applied: AppliedEdit[] = []; + + edits.forEach((edit, index) => { + const { old_string: oldString, new_string: newString, replace_all: replaceAll } = edit; + + if (typeof oldString !== 'string' || oldString.length === 0) { + throw new PageContentError(`Edit ${index}: old_string must be a non-empty string`, { + edit_index: index, + }); + } + + if (oldString === newString) { + throw new PageContentError(`Edit ${index}: old_string and new_string are identical`, { + edit_index: index, + }); + } + + const occurrences = countOccurrences(current, oldString); + + if (occurrences === 0) { + const whitespaceMatch = findWhitespaceTolerantMatch(current, oldString); + throw new PageContentError(`Edit ${index}: old_string not found in page content`, { + edit_index: index, + occurrences: 0, + found_with_different_whitespace: whitespaceMatch, + hint: whitespaceMatch + ? 'The text exists but with different whitespace. Retry with the exact text shown in found_with_different_whitespace.' + : 'Use bookstack_pages_read with the grep parameter to obtain an exact anchor string.', + }); + } + + if (occurrences > 1 && !replaceAll) { + const contexts: string[] = []; + let searchFrom = 0; + while (contexts.length < 3) { + const at = current.indexOf(oldString, searchFrom); + if (at === -1) { + break; + } + contexts.push(contextAround(current, at)); + searchFrom = at + oldString.length; + } + + throw new PageContentError( + `Edit ${index}: old_string is not unique (${occurrences} occurrences)`, + { + edit_index: index, + occurrences, + first_occurrences: contexts, + hint: 'Extend old_string with surrounding text to make it unique, or set replace_all to true.', + } + ); + } + + const firstOffset = current.indexOf(oldString); + current = replaceAll + ? current.split(oldString).join(newString) + : `${current.slice(0, firstOffset)}${newString}${current.slice(firstOffset + oldString.length)}`; + + applied.push({ + index, + occurrences_replaced: replaceAll ? occurrences : 1, + context: contextAround(current, firstOffset), + }); + }); + + return { result: current, applied }; +} + +/** + * Strip tags and decode the handful of entities BookStack emits in headings. + */ +function htmlToText(html: string): string { + return html + .replace(/<[^>]*>/g, '') + .replace(/ /g, ' ') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/�?39;/g, "'") + .replace(/&/g, '&') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Reduce content to comparable text. + * + * BookStack rewrites stored HTML on save (heading anchors, `id` attributes), + * so written content is verified on its text, not byte for byte. + */ +export function normalizeForComparison(value: string, writeField: PageWriteField): string { + const text = writeField === 'markdown' ? value : htmlToText(value); + return text.replace(/\s+/g, ' ').trim(); +} + +/** + * Check whether a fragment is present, ignoring markup normalisation. + */ +export function containsNormalized( + haystack: string, + needle: string, + writeField: PageWriteField +): boolean { + const normalizedNeedle = normalizeForComparison(needle, writeField); + if (normalizedNeedle.length === 0) { + return true; + } + return normalizeForComparison(haystack, writeField).includes(normalizedNeedle); +} + +/** + * Map the heading structure of a page, so a large page can be navigated + * without loading its content. + */ +export function buildOutline(source: string, writeField: PageWriteField): Heading[] { + const headings: Omit[] = []; + + // `matchAll` rather than a `while ((m = re.exec(s)))` loop: the assignment-in-condition + // form is what the linter flags, and matchAll also advances past a zero-length match on its + // own, which that loop has to remember to do by hand. + if (writeField === 'markdown') { + for (const match of source.matchAll(/^(#{1,6})[ \t]+(.+?)[ \t]*#*$/gm)) { + headings.push({ + level: match[1].length, + text: match[2].trim(), + offset: match.index, + }); + } + } else { + for (const match of source.matchAll(/]*>([\s\S]*?)<\/h\1>/gi)) { + headings.push({ + level: Number(match[1]), + text: htmlToText(match[2]), + offset: match.index, + }); + } + } + + return headings.map((heading, i) => ({ + ...heading, + length: (i + 1 < headings.length ? headings[i + 1].offset : source.length) - heading.offset, + })); +} + +/** + * Search inside page content and return exact matches with surrounding + * context, suitable for building an `old_string` anchor. + */ +export function grepContent( + source: string, + query: string, + options: { caseInsensitive?: boolean; contextChars?: number; maxMatches?: number } = {} +): { matches: GrepMatch[]; total: number; truncated: boolean } { + const { caseInsensitive = true, contextChars = 200, maxMatches = 10 } = options; + + if (query.length === 0) { + throw new PageContentError('Search text must be non-empty'); + } + + // The caller controls only literal text: escaping it before compiling preserves literal + // semantics and prevents backtracking or a whole-page match. RegExp also returns offsets in + // the original source, unlike lowercasing the full source (which can change its UTF-16 length). + const regex = new RegExp(escapeRegExp(query), caseInsensitive ? 'gi' : 'g'); + const matches: GrepMatch[] = []; + let total = 0; + + // `total` counts EVERY match while only `maxMatches` are collected: a truncated result that + // under-reported the total would read as "this anchor is unique" and a caller would edit on + // that basis. + for (const match of source.matchAll(regex)) { + total += 1; + if (matches.length < maxMatches) { + matches.push({ + offset: match.index, + match: match[0], + context: contextAround(source, match.index, contextChars), + }); + } + } + + return { matches, total, truncated: total > matches.length }; +} + +/** + * Insert content at the start or end of a page, or of a named section. + */ +export function insertContent( + source: string, + content: string, + options: { + position?: 'start' | 'end'; + section?: string; + separator?: string; + writeField: PageWriteField; + } +): string { + const { position = 'end', section, writeField } = options; + const separator = options.separator ?? (writeField === 'markdown' ? '\n\n' : '\n'); + + let rangeStart = 0; + let rangeEnd = source.length; + + if (section) { + const headings = buildOutline(source, writeField); + const wanted = section.trim().toLowerCase(); + const found = + headings.find((h) => h.text.toLowerCase() === wanted) ?? + headings.find((h) => h.text.toLowerCase().includes(wanted)); + + if (!found) { + throw new PageContentError(`Section not found: ${section}`, { + available_sections: headings.map((h) => h.text), + hint: 'Use bookstack_pages_outline to list the exact heading texts.', + }); + } + + // "start" means directly after the heading itself, not before it + const headingBlock = source.slice(found.offset, found.offset + found.length); + let headingEnd: number; + if (writeField === 'markdown') { + const lineBreak = headingBlock.indexOf('\n'); + headingEnd = found.offset + (lineBreak === -1 ? headingBlock.length : lineBreak); + } else { + const closing = /<\/h[1-6]>/i.exec(headingBlock); + headingEnd = + found.offset + (closing ? closing.index + closing[0].length : headingBlock.length); + } + + rangeStart = headingEnd; + rangeEnd = found.offset + found.length; + } + + let insertAt = rangeStart; + if (position === 'end') { + // Step back over trailing whitespace so the inserted text stays inside + // the section instead of being pushed against the next heading + insertAt = rangeEnd; + while (insertAt > rangeStart && /\s/.test(source[insertAt - 1])) { + insertAt -= 1; + } + } + + return insertAt > 0 + ? `${source.slice(0, insertAt)}${separator}${content}${source.slice(insertAt)}` + : `${content}${separator}${source.slice(insertAt)}`; +} + +/** + * Refuse writes that would drop a large part of the page, unless the + * caller explicitly opted in. Guards against an anchor that accidentally + * swallows most of a document. + */ +export function assertNoUnexpectedShrink(before: string, after: string, allowShrink = false): void { + if (allowShrink || before.length === 0) { + return; + } + + if (after.length < before.length * 0.5) { + throw new PageContentError('Refusing to write: result is less than half the original size', { + chars_before: before.length, + chars_after: after.length, + hint: 'Set allow_shrink to true if this reduction is intended.', + }); + } +} + +/** + * Extract a character window from page content. + */ +export function sliceContent( + source: string, + offset = 0, + length?: number +): { + content: string; + offset: number; + length: number; + total_chars: number; + truncated: boolean; +} { + const start = Math.max(0, Math.min(offset, source.length)); + const end = length === undefined ? source.length : Math.min(source.length, start + length); + const content = source.slice(start, end); + + return { + content, + offset: start, + length: content.length, + total_chars: source.length, + truncated: end < source.length, + }; +} diff --git a/src/validation/validator.ts b/src/validation/validator.ts index dd96938..2d3d6e3 100644 --- a/src/validation/validator.ts +++ b/src/validation/validator.ts @@ -208,6 +208,46 @@ export interface ExportRequest extends IdRequest { format: ExportFormat; } +/** + * `bookstack_pages_read`. Every option has a schema default, so the handler reads plain + * values rather than re-deciding what "unset" means; only the three genuinely absent-or-not + * options are optional. + */ +export interface PageReadRequest extends IdRequest { + grep?: string; + case_sensitive: boolean; + context: number; + max_matches: number; + offset?: number; + length?: number; + metadata_only: boolean; +} + +/** One literal replacement in a `bookstack_pages_edit` request. */ +export interface PageEditOperation { + old_string: string; + new_string: string; + replace_all: boolean; +} + +/** `bookstack_pages_edit`: the page, the edits, and the guards around applying them. */ +export interface PageEditRequest extends IdRequest { + edits: PageEditOperation[]; + dry_run: boolean; + expected_updated_at?: string; + allow_shrink: boolean; +} + +/** `bookstack_pages_append`: the page, the fragment, and where it goes. */ +export interface PageAppendRequest extends IdRequest { + content: string; + position: 'start' | 'end'; + section?: string; + separator?: string; + dry_run: boolean; + expected_updated_at?: string; +} + /** How the permission tools address an item: by type AND id, since ids repeat per type. */ export interface ContentPermissionsRequest { content_type: ContentType; @@ -360,6 +400,68 @@ const ValidationSchemas = { priority: z.number().int().optional(), }), + /** + * `bookstack_pages_read`, which takes an id plus the options that narrow what comes back. + * + * A separate schema rather than the shared `id` one because that one is `strictObject` + * and would reject every option here. A read with no options set behaves exactly as + * before, so the plain `{id}` call is unchanged. + */ + pageRead: z.strictObject({ + id: entityId, + // Literal search is capped so one result cannot smuggle an entire large page through the + // narrowed-read response. The handler returns the literal query as the match value. + grep: z.string().min(1).max(1000).optional(), + case_sensitive: z.boolean().default(false), + // The upper bound is the one that matters: grep exists to avoid shipping the whole page. + // The lower bound only keeps the window non-empty and non-negative. A very narrow excerpt + // is useless but not unsafe, and `minimum: 1` is what the published schema can state + // without adding a fifth integer rule for one property (see + // tests/unit/id-schema-contract.test.ts). + context: z.number().int().min(1).max(2000).default(200), + max_matches: z.number().int().min(1).max(50).default(10), + offset: z.number().int().min(0).optional(), + length: z.number().int().min(1).optional(), + metadata_only: z.boolean().default(false), + }), + + /** + * `bookstack_pages_edit`: literal find-and-replace against the stored page source. + * + * `old_string` is `.min(1)` because an empty anchor matches nothing meaningful and + * `applyEdits()` refuses it anyway - rejecting here means the caller gets told which + * parameter was wrong instead of a generic failure. `new_string` has no minimum: the empty + * string is how a caller deletes the anchored text. + */ + pageEdit: z.strictObject({ + id: entityId, + edits: z + .array( + z.strictObject({ + old_string: z.string().min(1), + new_string: z.string(), + replace_all: z.boolean().default(false), + }) + ) + .min(1), + dry_run: z.boolean().default(false), + // Compared byte-for-byte against the page's current `updated_at`, so it is not parsed + // or reformatted here: whatever BookStack reported is what must come back. + expected_updated_at: z.string().min(1).optional(), + allow_shrink: z.boolean().default(false), + }), + + /** `bookstack_pages_append`: insert a fragment at a page or section boundary. */ + pageAppend: z.strictObject({ + id: entityId, + content: z.string().min(1), + position: z.enum(['start', 'end']).default('end'), + section: z.string().min(1).optional(), + separator: z.string().optional(), + dry_run: z.boolean().default(false), + expected_updated_at: z.string().min(1).optional(), + }), + // Chapters // `created_by` is advertised by the tool and supported by BookStack, but was // absent here - so zod stripped it and the call silently came back unfiltered. diff --git a/tests/integration/pages.test.ts b/tests/integration/pages.test.ts index 7d2095a..e622ac4 100644 --- a/tests/integration/pages.test.ts +++ b/tests/integration/pages.test.ts @@ -343,7 +343,7 @@ describe.skipIf(!runIntegration)('bookstack_pages_* tools (live BookStack)', () await cleanup.run(harness); }, 180_000); - it('registers all six page tools', () => { + it('registers the six original page tools plus the three partial-editing tools', () => { const names = pageTools.getTools().map((tool) => tool.name); expect(names).toEqual([ @@ -351,6 +351,9 @@ describe.skipIf(!runIntegration)('bookstack_pages_* tools (live BookStack)', () 'bookstack_pages_create', 'bookstack_pages_read', 'bookstack_pages_update', + 'bookstack_pages_edit', + 'bookstack_pages_append', + 'bookstack_pages_outline', 'bookstack_pages_delete', 'bookstack_pages_export', ]); diff --git a/tests/integration/system.test.ts b/tests/integration/system.test.ts index 0ab4ef0..8f0b72b 100644 --- a/tests/integration/system.test.ts +++ b/tests/integration/system.test.ts @@ -40,6 +40,7 @@ import type { import { ErrorHandler } from '../../src/utils/errors'; import { Logger } from '../../src/utils/logger'; import { ValidationHandler } from '../../src/validation/validator'; +import { VERSION } from '../../src/version'; import { apiUrl, appUrl, @@ -136,7 +137,7 @@ describe.skipIf(!runIntegration)('system + server-info tools (live BookStack)', const config: Config = { bookstack: { baseUrl: harness.baseUrl, apiToken: harness.token, timeout: 30_000 }, - server: { name: 'bookstack-mcp-server', version: '1.0.0', port: 3000 }, + server: { name: 'bookstack-mcp-server', version: VERSION, port: 3000 }, // The production defaults. Every suite here authenticates as the same admin // user, so pacing outbound calls keeps one suite from starving its // neighbours even where the instance itself would allow more. @@ -316,7 +317,7 @@ describe.skipIf(!runIntegration)('system + server-info tools (live BookStack)', const info = (await callTool('bookstack_server_info', {})) as MCPServerInfo; expect(info.name).toBe('BookStack MCP Server'); - expect(info.version).toBe('1.0.0'); + expect(info.version).toBe(VERSION); expect(info.capabilities.tools.total).toBe(toolsMap.size); expect(info.capabilities.resources.total).toBe(resourcesMap.size); expect(info.capabilities.authentication.required).toBe(true); @@ -575,11 +576,11 @@ describe.skipIf(!runIntegration)('system + server-info tools (live BookStack)', it('serves real content for every workflow its enum advertises', async () => { // This tool was wholly non-functional: lookup was // `title.toLowerCase().includes(workflow)`, and since every enum value carries - // an underscore and no title does, all five values returned "Workflow not + // an underscore and no title does, all values returned "Workflow not // found" β€” two of them had no content behind them at all. Every advertised // value must now come back as a fully-populated workflow. const workflows = advertisedEnum('bookstack_usage_examples', 'workflow'); - expect(workflows).toHaveLength(5); + expect(workflows).toHaveLength(6); for (const workflow of workflows) { const example = (await callTool('bookstack_usage_examples', { diff --git a/tests/transport/stdio.test.ts b/tests/transport/stdio.test.ts index 9e99945..fe14421 100644 --- a/tests/transport/stdio.test.ts +++ b/tests/transport/stdio.test.ts @@ -298,7 +298,7 @@ describe('stdio entry point', () => { }, 20_000); it( - 'completes an MCP handshake and lists all 56 tools', + 'completes an MCP handshake and lists all 59 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. @@ -312,7 +312,7 @@ describe('stdio entry point', () => { const listReply = parseProtocolLine(await server.nextStdoutLine()); expect(listReply.id).toBe(2); - expect(listReply.result?.tools).toHaveLength(56); + expect(listReply.result?.tools).toHaveLength(59); expect(listReply.result?.tools?.map((tool) => tool.name)).toContain('bookstack_books_list'); }, REPLY_TIMEOUT_MS + 5_000 diff --git a/tests/transport/tools.test.ts b/tests/transport/tools.test.ts index 9d651a0..b0b87a1 100644 --- a/tests/transport/tools.test.ts +++ b/tests/transport/tools.test.ts @@ -58,10 +58,13 @@ const EXPECTED_TOOLS = [ 'bookstack_images_list', 'bookstack_images_read', 'bookstack_images_update', + 'bookstack_pages_append', 'bookstack_pages_create', 'bookstack_pages_delete', + 'bookstack_pages_edit', 'bookstack_pages_export', 'bookstack_pages_list', + 'bookstack_pages_outline', 'bookstack_pages_read', 'bookstack_pages_update', 'bookstack_permissions_read', @@ -338,9 +341,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 56-entry diff. + // Count first: a bare length mismatch reports far more clearly than a 59-entry diff. expect(names).toHaveLength(EXPECTED_TOOLS.length); - expect(names).toHaveLength(56); + expect(names).toHaveLength(59); expect(names).toEqual([...EXPECTED_TOOLS]); }); diff --git a/tests/unit/id-schema-contract.test.ts b/tests/unit/id-schema-contract.test.ts index 23dad75..f78b050 100644 --- a/tests/unit/id-schema-contract.test.ts +++ b/tests/unit/id-schema-contract.test.ts @@ -128,17 +128,27 @@ const RULES: Record = { 'bookstack_images_update.id': 'entity-id', // --- Pages --- + 'bookstack_pages_append.id': 'entity-id', 'bookstack_pages_create.book_id': 'entity-id', 'bookstack_pages_create.chapter_id': 'entity-id', 'bookstack_pages_create.priority': 'unbounded', 'bookstack_pages_delete.id': 'entity-id', + 'bookstack_pages_edit.id': 'entity-id', 'bookstack_pages_export.id': 'entity-id', 'bookstack_pages_list.count': 'positive-count', 'bookstack_pages_list.filter.book_id': 'entity-id', 'bookstack_pages_list.filter.chapter_id': 'entity-id', 'bookstack_pages_list.filter.created_by': 'entity-id', 'bookstack_pages_list.offset': 'non-negative', + 'bookstack_pages_outline.id': 'entity-id', + // The narrowing options on a read. `context`, `length` and `max_matches` are window sizes: + // a window of nothing is not a request worth making, so they carry `minimum: 1` like a + // listing's `count`. `offset` is a position, where 0 is the start of the page. + 'bookstack_pages_read.context': 'positive-count', 'bookstack_pages_read.id': 'entity-id', + 'bookstack_pages_read.length': 'positive-count', + 'bookstack_pages_read.max_matches': 'positive-count', + 'bookstack_pages_read.offset': 'non-negative', 'bookstack_pages_update.book_id': 'entity-id', // There is no value meaning "no chapter": `pageUpdate.chapter_id` is `entityId`, so 0 is // rejected rather than read as "detach". Moving a page to its book root is `book_id` alone. @@ -226,6 +236,11 @@ const TOOL_BASES: Record> = { bookstack_images_list: { count: 20, offset: 0, filter: { uploaded_to: 1 } }, bookstack_images_read: { id: 1 }, bookstack_images_update: { id: 1, name: 'Probe' }, + // 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 + // then fails on an empty page, after the call that counts has already been made. + bookstack_pages_append: { id: 1, content: '

x

' }, bookstack_pages_create: { name: 'Probe', book_id: 1, @@ -234,12 +249,14 @@ const TOOL_BASES: Record> = { priority: 1, }, bookstack_pages_delete: { id: 1 }, + bookstack_pages_edit: { id: 1, edits: [{ old_string: 'x', new_string: 'y' }] }, bookstack_pages_export: { id: 1, format: 'pdf' }, bookstack_pages_list: { count: 20, offset: 0, filter: { book_id: 1, chapter_id: 1, created_by: 1 }, }, + bookstack_pages_outline: { id: 1 }, bookstack_pages_read: { id: 1 }, bookstack_pages_update: { id: 1, book_id: 1, chapter_id: 1, priority: 1 }, bookstack_permissions_read: { content_type: 'book', content_id: 1 }, diff --git a/tests/unit/page-content.test.ts b/tests/unit/page-content.test.ts new file mode 100644 index 0000000..d8d2e6b --- /dev/null +++ b/tests/unit/page-content.test.ts @@ -0,0 +1,406 @@ +/** + * Unit tests for the partial-page-editing helpers. + * + * These are pure functions - no client, no HTTP, no BookStack. What they encode is the two + * invariants the whole feature rests on, so they are asserted directly rather than through a + * tool handler: + * + * - a markdown page is patched and written through `markdown`, because writing `html` to one + * switches its editor type; + * - every other page is patched against `raw_html`, the STORED source, never against `html`, + * the rendered output - patching the rendered output writes back expanded page-include + * tags and destroys the includes permanently. + * + * The diagnostics are under test as much as the results: an anchor that does not match is the + * normal case for a model driving these tools, and what it gets back - the same text found + * with different whitespace, the first few ambiguous matches, the list of real section names - + * is what lets it fix its own call instead of guessing. + */ + +import { describe, expect, it } from 'bun:test'; +import type { PageWithContent } from '../../src/types'; +import { + applyEdits, + assertNoUnexpectedShrink, + buildOutline, + containsNormalized, + countOccurrences, + grepContent, + insertContent, + PageContentError, + selectSource, + sliceContent, +} from '../../src/utils/page-content'; + +const basePage: PageWithContent = { + id: 1, + book_id: 2, + chapter_id: null, + name: 'Test page', + slug: 'test-page', + priority: 0, + draft: false, + template: false, + created_at: '2026-01-01T00:00:00.000000Z', + updated_at: '2026-01-02T00:00:00.000000Z', + created_by: 1, + updated_by: 1, + owned_by: 1, + revision_count: 3, + editor: 'wysiwyg', + tags: [], + html: '

rendered

', + raw_html: '

stored

', +}; + +/** Capture the error a thunk throws, without depending on a `fail()` helper. */ +function thrownBy(run: () => unknown): unknown { + try { + run(); + } catch (error) { + return error; + } + return undefined; +} + +describe('selectSource', () => { + it('patches the stored html, not the rendered html', () => { + // THE INVARIANT. `html` is what BookStack renders, with page includes resolved; + // `raw_html` is what it stores. Patching the rendered output and writing it back would + // replace every `{{@42}}` include with a frozen copy of its target, irreversibly. + const result = selectSource(basePage); + + expect(result.writeField).toBe('html'); + expect(result.source).toBe('

stored

'); + }); + + it('patches and writes markdown for markdown pages', () => { + const result = selectSource({ + ...basePage, + editor: 'markdown', + markdown: '# Heading\n\nBody', + }); + + expect(result.writeField).toBe('markdown'); + expect(result.source).toBe('# Heading\n\nBody'); + }); + + it('falls back to the rendered html when raw_html is absent', () => { + const { raw_html: _omitted, ...withoutRaw } = basePage; + const result = selectSource(withoutRaw as PageWithContent); + + expect(result.source).toBe('

rendered

'); + }); + + it('ignores a blank markdown field on a markdown page that has stored html', () => { + const result = selectSource({ ...basePage, editor: 'markdown', markdown: ' ' }); + + expect(result.writeField).toBe('html'); + }); + + it('keeps writing markdown on an empty markdown page', () => { + // Falling back to html here would flip the editor type on the very first append to a + // freshly created markdown page - the one case where there is no content to judge by. + const result = selectSource({ + ...basePage, + editor: 'markdown', + markdown: '', + raw_html: '', + html: '', + }); + + expect(result.writeField).toBe('markdown'); + expect(result.source).toBe(''); + }); +}); + +describe('countOccurrences', () => { + it('counts literally, without regex interpretation', () => { + // '.' as a regex would match every character. Anchors are caller-supplied prose full of + // dots, brackets and parentheses, so the match has to be literal. + expect(countOccurrences('a.b.c', '.')).toBe(2); + // Non-overlapping, which is what a sequence of replacements will actually do. + expect(countOccurrences('aaa', 'aa')).toBe(1); + expect(countOccurrences('abc', '')).toBe(0); + }); +}); + +describe('applyEdits', () => { + const source = 'Intro paragraph.\n\nData is transferred on request.\n\nOutro.'; + + it('replaces a unique anchor', () => { + const { result, applied } = applyEdits(source, [ + { + old_string: 'Data is transferred on request.', + new_string: 'Data is transferred only with consent.', + }, + ]); + + expect(result).toContain('only with consent'); + expect(result).not.toContain('on request.'); + expect(applied[0].occurrences_replaced).toBe(1); + }); + + it('applies multiple edits in order, each to the previous result', () => { + const { result, applied } = applyEdits(source, [ + { old_string: 'Intro paragraph.', new_string: 'Introduction.' }, + { old_string: 'Outro.', new_string: 'Conclusion.' }, + ]); + + expect(result).toContain('Introduction.'); + expect(result).toContain('Conclusion.'); + expect(applied).toHaveLength(2); + }); + + it('rejects an anchor that is not present', () => { + expect(() => applyEdits(source, [{ old_string: 'missing text', new_string: 'x' }])).toThrow( + PageContentError + ); + }); + + it('reports the exact text when only the whitespace differs', () => { + // The most common near-miss by far: a model reproduces an anchor with collapsed + // whitespace, because that is how the text reads. Saying "not found" and stopping there + // would leave it with no way forward, so the diagnostic carries the real bytes. + const error = thrownBy(() => + applyEdits('

One long\nsentence

', [ + { old_string: 'One long sentence', new_string: 'x' }, + ]) + ); + + expect(error).toBeInstanceOf(PageContentError); + expect((error as PageContentError).details?.found_with_different_whitespace).toBe( + 'One long\nsentence' + ); + }); + + it('refuses an ambiguous anchor, and reports where the matches are', () => { + const repeated = 'yes. yes. yes.'; + + const error = thrownBy(() => applyEdits(repeated, [{ old_string: 'yes.', new_string: 'no.' }])); + expect((error as PageContentError).message).toMatch(/not unique \(3 occurrences\)/); + expect((error as PageContentError).details?.first_occurrences).toBeArray(); + + // replace_all is the explicit opt-in for a rename that legitimately repeats. + const { result, applied } = applyEdits(repeated, [ + { old_string: 'yes.', new_string: 'no.', replace_all: true }, + ]); + expect(result).toBe('no. no. no.'); + expect(applied[0].occurrences_replaced).toBe(3); + }); + + it('rejects empty, no-op and absent edits', () => { + expect(() => applyEdits(source, [{ old_string: '', new_string: 'x' }])).toThrow( + PageContentError + ); + expect(() => applyEdits(source, [{ old_string: 'Outro.', new_string: 'Outro.' }])).toThrow( + PageContentError + ); + expect(() => applyEdits(source, [])).toThrow(PageContentError); + }); + + it('replaces only the first occurrence once the anchor is unique', () => { + const { result } = applyEdits('one two one', [ + { old_string: 'one two', new_string: 'ONE TWO' }, + ]); + + expect(result).toBe('ONE TWO one'); + }); +}); + +describe('buildOutline', () => { + it('maps markdown headings with offsets and section sizes', () => { + const markdown = '# Title\n\nText\n\n## Section A\n\nMore text\n\n## Section B\n\nEnd'; + const headings = buildOutline(markdown, 'markdown'); + + expect(headings.map((heading) => heading.text)).toEqual(['Title', 'Section A', 'Section B']); + expect(headings[0].level).toBe(1); + expect(headings[1].level).toBe(2); + expect(headings[0].offset).toBe(0); + // A heading's `length` is its SECTION's size, so the last one has to reach the end of + // the document - that is what makes the offsets usable as insertion ranges. + expect(headings[2].offset + headings[2].length).toBe(markdown.length); + }); + + it('maps html headings, stripping the markup and entities BookStack emits', () => { + // `id="bkmrk-…"` is injected by BookStack on save, and `&` is how it stores an + // ampersand. A section name a caller can actually type has to survive both. + const html = + '

Title & more

Text

Section A

End

'; + const headings = buildOutline(html, 'html'); + + expect(headings.map((heading) => heading.text)).toEqual(['Title & more', 'Section A']); + expect(headings[1].level).toBe(2); + }); + + it('returns an empty outline for a page without headings', () => { + expect(buildOutline('

Just a paragraph

', 'html')).toEqual([]); + }); +}); + +describe('grepContent', () => { + const source = 'Line one\nLine two\nLine three'; + + it('returns literal matches with their offsets, matched text and context', () => { + const { matches, total, truncated } = grepContent(source, 'Line one', { contextChars: 5 }); + + expect(total).toBe(1); + expect(truncated).toBe(false); + expect(matches[0].match).toBe('Line one'); + expect(matches[0].offset).toBe(source.indexOf('Line one')); + }); + + it('honours maxMatches while still reporting the true total', () => { + // A truncated result that under-reported the total would read as "there is one match", + // and a caller would anchor on it believing it unique. + const { matches, total, truncated } = grepContent(source, 'Line', { maxMatches: 1 }); + + expect(matches).toHaveLength(1); + expect(total).toBe(3); + expect(truncated).toBe(true); + }); + + it('is case insensitive by default and case sensitive on request', () => { + expect(grepContent(source, 'line').total).toBe(3); + expect(grepContent(source, 'line', { caseInsensitive: false }).total).toBe(0); + }); + + it('keeps offsets and excerpts aligned when case folding expands an earlier character', () => { + const unicodeSource = 'Δ°retention period'; + const result = grepContent(unicodeSource, 'retention period'); + + expect(result.matches).toEqual([ + { + offset: 1, + match: 'retention period', + context: unicodeSource, + }, + ]); + }); + + it('treats regex syntax literally so it cannot return the whole page as one match', () => { + const wholePagePattern = '[\\s\\S]*'; + + expect( + grepContent('All of this content must stay out of the response', wholePagePattern) + ).toEqual({ + matches: [], + total: 0, + truncated: false, + }); + }); +}); + +describe('insertContent', () => { + const markdown = '# Title\n\nIntro\n\n## Measures\n\nExisting text\n\n## Other\n\nEnd'; + + it('appends at the end of the page', () => { + const result = insertContent(markdown, 'New sentence', { writeField: 'markdown' }); + + expect(result.endsWith('End\n\nNew sentence')).toBe(true); + }); + + it('appends at the end of a named section, before the next heading', () => { + // Section targeting only works if the text lands inside the section it was addressed to, + // instead of being pushed past its boundary into the following one. + const result = insertContent(markdown, 'New sentence', { + writeField: 'markdown', + section: 'Measures', + }); + + expect(result).toContain('Existing text\n\nNew sentence\n\n## Other'); + }); + + it('inserts directly after a section heading with position: start', () => { + const result = insertContent(markdown, 'New sentence', { + writeField: 'markdown', + section: 'Measures', + position: 'start', + }); + + expect(result).toContain('## Measures\n\nNew sentence'); + }); + + it('inserts after an html section heading', () => { + const html = '

Measures

Old

Other

End

'; + const result = insertContent(html, '

New

', { + writeField: 'html', + section: 'Measures', + position: 'start', + separator: '', + }); + + expect(result).toContain('

Measures

New

Old

'); + }); + + it('matches a section name case-insensitively', () => { + const result = insertContent(markdown, 'New sentence', { + writeField: 'markdown', + section: 'measures', + }); + + expect(result).toContain('Existing text\n\nNew sentence'); + }); + + it('lists the real section names when the section is unknown', () => { + const error = thrownBy(() => + insertContent(markdown, 'x', { writeField: 'markdown', section: 'Absent' }) + ); + + expect(error).toBeInstanceOf(PageContentError); + expect((error as PageContentError).details?.available_sections).toEqual([ + 'Title', + 'Measures', + 'Other', + ]); + }); +}); + +describe('assertNoUnexpectedShrink', () => { + it('allows an ordinary edit', () => { + expect(() => assertNoUnexpectedShrink('a'.repeat(100), 'a'.repeat(80))).not.toThrow(); + }); + + it('blocks a drastic reduction unless it was asked for', () => { + // The failure this guards: an anchor whose closing text appears far earlier than the + // author meant, so the replacement swallows most of the document. The write is refused + // rather than applied, because BookStack's revision history is the only way back. + expect(() => assertNoUnexpectedShrink('a'.repeat(100), 'a'.repeat(10))).toThrow( + PageContentError + ); + expect(() => assertNoUnexpectedShrink('a'.repeat(100), 'a'.repeat(10), true)).not.toThrow(); + }); +}); + +describe('containsNormalized', () => { + it('recognises written content after BookStack rewrote the markup', () => { + // BookStack re-generates heading anchors and injects `id` attributes on save, so the + // bytes that come back are not the bytes that were sent. Verifying byte-for-byte would + // report every successful write as unverified. + const stored = '

Data is transferred only with consent.

'; + + expect( + containsNormalized(stored, '

Data is transferred only with consent.

', 'html') + ).toBe(true); + expect(containsNormalized(stored, '

Something else entirely

', 'html')).toBe(false); + }); + + it('compares markdown on collapsed whitespace', () => { + expect(containsNormalized('# Title\n\nOne sentence', 'One sentence', 'markdown')).toBe(true); + }); +}); + +describe('sliceContent', () => { + it('returns a window and reports whether more follows', () => { + const result = sliceContent('0123456789', 2, 3); + + expect(result.content).toBe('234'); + expect(result.offset).toBe(2); + expect(result.total_chars).toBe(10); + expect(result.truncated).toBe(true); + }); + + it('clamps an out-of-range offset instead of throwing', () => { + expect(sliceContent('abc', 99).content).toBe(''); + }); +}); diff --git a/tests/unit/pages.test.ts b/tests/unit/pages.test.ts new file mode 100644 index 0000000..144815f --- /dev/null +++ b/tests/unit/pages.test.ts @@ -0,0 +1,547 @@ +/** + * Unit tests for the partial-page-editing tools. + * + * What is under test here is the handler behaviour the pure helpers cannot show: which field + * gets written, that nothing is written when a guard fires, and that the responses carry no + * page content. That last point is the feature's reason for existing - if a response echoed + * the page back, the content would travel through the model anyway. + * + * The validator is REAL, not a stub. These tools lean on schema defaults (`dry_run`, + * `position`, `context`, `max_matches`) and on `strictObject` rejecting unknown keys, so a + * stubbed validator that waved input through would test a contract nobody ships. + * + * No `mock.module()`: `PageTools` takes its three collaborators via the constructor, so the + * modules are never loaded at runtime here. Bun's module-mock registry is process-global and + * would leak into every other file in the run. + */ + +import { beforeEach, describe, expect, it, type Mock, mock } from 'bun:test'; +import type { BookStackClient } from '../../src/api/client'; +import { PageTools } from '../../src/tools/pages'; +import type { MCPTool, PageWithContent } from '../../src/types'; +import type { Logger } from '../../src/utils/logger'; +import { ValidationHandler } from '../../src/validation/validator'; + +/** + * Types a subset of `T`'s methods, each as a bun:test `Mock` carrying its real signature. + * + * bun:test has no `jest.Mocked` equivalent, so this derives what is needed. Deriving from + * the real declarations keeps the stubs honest if a signature changes, while naming only the + * methods under test keeps them robust to the client gaining unrelated ones. + */ +type MockedMethods = { + [P in K]: T[P] extends (...args: infer A) => infer R ? Mock<(...args: A) => R> : never; +}; + +type MockClient = MockedMethods; +type MockLogger = MockedMethods; + +const STORED = '

First paragraph

Second paragraph

'; +const PAGE_ID = 42; + +/** + * A page fixture. + * + * `html` differs from `raw_html` on purpose here: it stands for the RENDERED output, with + * page includes resolved. Several assertions below check that the tools read `raw_html` and + * never `html`, since patching the rendered output would write expanded include tags back + * into the page. + */ +const page = (overrides: Partial = {}): PageWithContent => ({ + id: PAGE_ID, + book_id: 12, + chapter_id: null, + name: 'Test page', + slug: 'test-page', + priority: 0, + draft: false, + template: false, + created_at: '2026-01-01T00:00:00.000000Z', + updated_at: '2026-01-02T00:00:00.000000Z', + created_by: 1, + updated_by: 1, + owned_by: 1, + revision_count: 7, + editor: 'wysiwyg', + tags: [], + html: '

rendered include

', + raw_html: STORED, + ...overrides, +}); + +/** The response shapes these tools return, as far as the assertions read them. */ +interface GrepResult { + total_matches: number; + total_chars: number; + matches: Array<{ match: string; offset: number }>; + content?: string; +} +interface OutlineResult { + heading_count: number; + headings: Array<{ text: string; level: number }>; +} +interface WriteResult { + written: boolean; + verified: boolean; + unverified_fragment_count: number; + revision_count: number; + updated_at: string; + chars_after: number; + delta: number; + dry_run?: boolean; + page_id: number; + field: string; + section: string | null; +} + +describe('PageTools partial editing', () => { + let pageTools: PageTools; + let mockClient: MockClient; + let mockLogger: MockLogger; + + const tool = (name: string): MCPTool => { + const found = pageTools.getTools().find((candidate) => candidate.name === name); + if (!found) { + throw new Error(`Tool not registered: ${name}`); + } + return found; + }; + + /** Call a handler and read the result as the shape the assertion expects. */ + const call = async (name: string, params: Record): Promise => + (await tool(name).handler(params)) as T; + + beforeEach(() => { + mockClient = { + getPage: mock(), + updatePage: mock(), + }; + + mockLogger = { + debug: mock(), + info: mock(), + warn: mock(), + error: mock(), + }; + + pageTools = new PageTools( + mockClient as unknown as BookStackClient, + new ValidationHandler({ enabled: true, strictMode: true }), + mockLogger as unknown as Logger + ); + }); + + describe('registration', () => { + it('publishes the six original tools plus the three editing ones', () => { + const names = pageTools.getTools().map((candidate) => candidate.name); + + expect(names).toEqual([ + 'bookstack_pages_list', + 'bookstack_pages_create', + 'bookstack_pages_read', + 'bookstack_pages_update', + 'bookstack_pages_edit', + 'bookstack_pages_append', + 'bookstack_pages_outline', + 'bookstack_pages_delete', + 'bookstack_pages_export', + ]); + }); + }); + + describe('bookstack_pages_read', () => { + it('returns the untouched page object when no option is set', async () => { + // Back compatibility, asserted by identity: a plain read must be exactly what this + // tool always returned, not a reshaped summary that happens to look similar. + const current = page(); + mockClient.getPage.mockResolvedValue(current); + + await expect(tool('bookstack_pages_read').handler({ id: PAGE_ID })).resolves.toBe(current); + }); + + it('returns excerpts and no content when grep is used', async () => { + mockClient.getPage.mockResolvedValue(page()); + + const result = await call('bookstack_pages_read', { + id: PAGE_ID, + grep: 'Second', + }); + + expect(result.total_matches).toBe(1); + expect(result.matches[0].match).toBe('Second'); + expect(result.content).toBeUndefined(); + expect(result.total_chars).toBe(STORED.length); + }); + + it('greps the stored source, not the rendered html', async () => { + // The invariant, from the outside: 'rendered' appears only in `html`. A match here + // would mean an anchor built from a grep result could never be found on write. + mockClient.getPage.mockResolvedValue(page()); + + const result = await call('bookstack_pages_read', { + id: PAGE_ID, + grep: 'rendered', + }); + + expect(result.total_matches).toBe(0); + }); + + it('returns size information without content for metadata_only', async () => { + mockClient.getPage.mockResolvedValue(page()); + + const result = await call('bookstack_pages_read', { + id: PAGE_ID, + metadata_only: true, + }); + + expect(result.page_id).toBe(PAGE_ID); + expect(result.field).toBe('html'); + expect(result.total_chars).toBe(STORED.length); + expect(result.matches).toBeUndefined(); + expect(result.content).toBeUndefined(); + }); + + it('rejects an unknown option rather than ignoring it', async () => { + mockClient.getPage.mockResolvedValue(page()); + + await expect( + tool('bookstack_pages_read').handler({ id: PAGE_ID, greps: 'typo' }) + ).rejects.toThrow(); + expect(mockClient.getPage).not.toHaveBeenCalled(); + }); + + it('rejects an oversized grep query before reading the page', async () => { + await expect( + tool('bookstack_pages_read').handler({ id: PAGE_ID, grep: 'x'.repeat(1001) }) + ).rejects.toThrow(); + expect(mockClient.getPage).not.toHaveBeenCalled(); + }); + }); + + describe('bookstack_pages_outline', () => { + it('returns headings and no page content', async () => { + mockClient.getPage.mockResolvedValue( + page({ raw_html: '

Section A

Text

Section B

Text

' }) + ); + + const result = await call('bookstack_pages_outline', { id: PAGE_ID }); + + expect(result.heading_count).toBe(2); + expect(result.headings.map((heading) => heading.text)).toEqual(['Section A', 'Section B']); + // The whole response, not just the headings array: this tool exists so that a large + // page can be navigated without its body reaching the model. + expect(JSON.stringify(result)).not.toContain('

Text

'); + }); + }); + + describe('bookstack_pages_edit', () => { + it('writes only the patched field and verifies what came back', async () => { + mockClient.getPage.mockResolvedValueOnce(page()).mockResolvedValueOnce( + page({ + // As BookStack stores it: an `id` attribute it injected on save. Verification + // has to see through that, which is why it compares normalised text. + raw_html: '

First paragraph

Third paragraph

', + updated_at: '2026-01-03T00:00:00.000000Z', + revision_count: 8, + }) + ); + mockClient.updatePage.mockResolvedValue(page()); + + const result = await call('bookstack_pages_edit', { + id: PAGE_ID, + edits: [{ old_string: 'Second paragraph', new_string: 'Third paragraph' }], + }); + + // Exactly one field, and it is `html` - carrying the patched RAW source. + expect(mockClient.updatePage).toHaveBeenCalledWith(PAGE_ID, { + html: '

First paragraph

Third paragraph

', + }); + expect(result.written).toBe(true); + expect(result.verified).toBe(true); + expect(result.revision_count).toBe(8); + expect(result.updated_at).toBe('2026-01-03T00:00:00.000000Z'); + }); + + it('writes the markdown field for a markdown page', async () => { + // Writing `html` to a markdown page switches its editor type, which a caller cannot + // undo from the API. + mockClient.getPage + .mockResolvedValueOnce(page({ editor: 'markdown', markdown: '# Title\n\nOld' })) + .mockResolvedValueOnce(page({ editor: 'markdown', markdown: '# Title\n\nNew' })); + mockClient.updatePage.mockResolvedValue(page()); + + await call('bookstack_pages_edit', { + id: PAGE_ID, + edits: [{ old_string: 'Old', new_string: 'New' }], + }); + + expect(mockClient.updatePage).toHaveBeenCalledWith(PAGE_ID, { + markdown: '# Title\n\nNew', + }); + }); + + it('writes nothing on a dry run', async () => { + mockClient.getPage.mockResolvedValue(page()); + + const result = await call('bookstack_pages_edit', { + id: PAGE_ID, + dry_run: true, + edits: [{ old_string: 'Second paragraph', new_string: 'Third paragraph' }], + }); + + expect(mockClient.updatePage).not.toHaveBeenCalled(); + expect(result.dry_run).toBe(true); + expect(result.written).toBe(false); + // Still the pre-edit timestamp, which is what the caller passes as the next + // expected_updated_at. + expect(result.updated_at).toBe('2026-01-02T00:00:00.000000Z'); + }); + + it('refuses to write when the page changed since it was read', async () => { + mockClient.getPage.mockResolvedValue(page()); + + await expect( + tool('bookstack_pages_edit').handler({ + id: PAGE_ID, + expected_updated_at: '2025-12-31T00:00:00.000000Z', + edits: [{ old_string: 'Second paragraph', new_string: 'Third paragraph' }], + }) + ).rejects.toThrow(/modified since it was read/); + + expect(mockClient.updatePage).not.toHaveBeenCalled(); + }); + + it('refuses to write when the anchor is absent', async () => { + mockClient.getPage.mockResolvedValue(page()); + + await expect( + tool('bookstack_pages_edit').handler({ + id: PAGE_ID, + edits: [{ old_string: 'does not occur', new_string: 'x' }], + }) + ).rejects.toThrow(/not found/); + + expect(mockClient.updatePage).not.toHaveBeenCalled(); + }); + + it('refuses to write when the result would shrink the page drastically', async () => { + mockClient.getPage.mockResolvedValue(page()); + + await expect( + tool('bookstack_pages_edit').handler({ + id: PAGE_ID, + edits: [{ old_string: STORED, new_string: '

x

' }], + }) + ).rejects.toThrow(/less than half/); + + expect(mockClient.updatePage).not.toHaveBeenCalled(); + }); + + it('reports an unverified write rather than claiming success', async () => { + // The second read returns the ORIGINAL content: the write went through but the change + // is not in what came back. That is a real possibility - a sanitiser dropping a tag - + // and the caller has to be told both facts: written, and not verified. + mockClient.getPage.mockResolvedValueOnce(page()).mockResolvedValueOnce(page()); + mockClient.updatePage.mockResolvedValue(page()); + + const result = await call('bookstack_pages_edit', { + id: PAGE_ID, + edits: [{ old_string: 'Second paragraph', new_string: 'Third paragraph' }], + }); + + expect(result.written).toBe(true); + expect(result.verified).toBe(false); + expect(result.unverified_fragment_count).toBe(1); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + + it('does not claim a deletion was verified when the old anchor remains', async () => { + // `new_string: ''` is an intentional deletion. If BookStack leaves the original source + // in place, verification has to test the old anchor's absence rather than treating an + // empty replacement as automatically present. + mockClient.getPage.mockResolvedValueOnce(page()).mockResolvedValueOnce(page()); + mockClient.updatePage.mockResolvedValue(page()); + + const result = await call('bookstack_pages_edit', { + id: PAGE_ID, + edits: [{ old_string: 'Second paragraph', new_string: '' }], + }); + + expect(result.written).toBe(true); + expect(result.verified).toBe(false); + expect(result.unverified_fragment_count).toBe(1); + }); + + it('does not claim a shrinking replacement was verified when the old anchor remains', async () => { + mockClient.getPage.mockResolvedValueOnce(page()).mockResolvedValueOnce(page()); + mockClient.updatePage.mockResolvedValue(page()); + + const result = await call('bookstack_pages_edit', { + id: PAGE_ID, + edits: [{ old_string: 'Second paragraph', new_string: 'Second' }], + }); + + expect(result.written).toBe(true); + expect(result.verified).toBe(false); + expect(result.unverified_fragment_count).toBe(1); + }); + + it('does not claim a markup-only replacement was verified when BookStack drops it', async () => { + mockClient.getPage.mockResolvedValueOnce(page()).mockResolvedValueOnce(page()); + mockClient.updatePage.mockResolvedValue(page()); + + const result = await call('bookstack_pages_edit', { + id: PAGE_ID, + edits: [{ old_string: 'Second paragraph', new_string: '
' }], + }); + + expect(result.written).toBe(true); + expect(result.verified).toBe(false); + expect(result.unverified_fragment_count).toBe(1); + }); + + it('verifies a markup-only replacement when BookStack keeps the markup', async () => { + const stored = '

First paragraph


'; + mockClient.getPage + .mockResolvedValueOnce(page()) + .mockResolvedValueOnce(page({ raw_html: stored })); + mockClient.updatePage.mockResolvedValue(page()); + + const result = await call('bookstack_pages_edit', { + id: PAGE_ID, + edits: [{ old_string: 'Second paragraph', new_string: '
' }], + }); + + expect(result.written).toBe(true); + expect(result.verified).toBe(true); + expect(result.unverified_fragment_count).toBe(0); + }); + + it('verifies chained edits against the final replacement, not an intermediate anchor', async () => { + mockClient.getPage + .mockResolvedValueOnce(page({ html: 'c', raw_html: 'a' })) + .mockResolvedValueOnce(page({ html: 'c', raw_html: 'c' })); + mockClient.updatePage.mockResolvedValue(page()); + + const result = await call('bookstack_pages_edit', { + id: PAGE_ID, + edits: [ + { old_string: 'a', new_string: 'b' }, + { old_string: 'b', new_string: 'c' }, + ], + }); + + expect(result.written).toBe(true); + expect(result.verified).toBe(true); + expect(result.unverified_fragment_count).toBe(0); + }); + + it('does not claim a chained edit was verified when the write is lost', async () => { + mockClient.getPage.mockResolvedValueOnce(page()).mockResolvedValueOnce(page()); + mockClient.updatePage.mockResolvedValue(page()); + + const result = await call('bookstack_pages_edit', { + id: PAGE_ID, + edits: [ + { old_string: 'Second paragraph', new_string: 'Zweiter Absatz' }, + { old_string: 'Zweiter Absatz', new_string: '' }, + ], + }); + + expect(result.written).toBe(true); + expect(result.verified).toBe(false); + expect(result.unverified_fragment_count).toBe(1); + }); + + it('does not claim a markup-only replacement was verified when it already exists', async () => { + const stored = '

First paragraph


Second paragraph

'; + mockClient.getPage + .mockResolvedValueOnce(page({ raw_html: stored })) + .mockResolvedValueOnce(page({ raw_html: stored })); + mockClient.updatePage.mockResolvedValue(page()); + + const result = await call('bookstack_pages_edit', { + id: PAGE_ID, + edits: [{ old_string: 'Second paragraph', new_string: '
' }], + }); + + expect(result.written).toBe(true); + expect(result.verified).toBe(false); + expect(result.unverified_fragment_count).toBe(1); + }); + + it('rejects an empty edit list at the schema boundary', async () => { + await expect( + tool('bookstack_pages_edit').handler({ id: PAGE_ID, edits: [] }) + ).rejects.toThrow(); + expect(mockClient.getPage).not.toHaveBeenCalled(); + }); + }); + + describe('bookstack_pages_append', () => { + it('appends to the end of the page', async () => { + mockClient.getPage + .mockResolvedValueOnce(page()) + .mockResolvedValueOnce(page({ raw_html: `${STORED}\n

New sentence

` })); + mockClient.updatePage.mockResolvedValue(page()); + + const result = await call('bookstack_pages_append', { + id: PAGE_ID, + content: '

New sentence

', + }); + + expect(mockClient.updatePage).toHaveBeenCalledWith(PAGE_ID, { + html: `${STORED}\n

New sentence

`, + }); + expect(result.verified).toBe(true); + expect(result.delta).toBe('\n

New sentence

'.length); + expect(result.section).toBeNull(); + }); + + it('appends inside a named section', async () => { + const withSections = '

Section A

Text

Section B

End

'; + const expected = '

Section A

Text

\n

Added

Section B

End

'; + mockClient.getPage + .mockResolvedValueOnce(page({ raw_html: withSections })) + .mockResolvedValueOnce(page({ raw_html: expected })); + mockClient.updatePage.mockResolvedValue(page()); + + const result = await call('bookstack_pages_append', { + id: PAGE_ID, + content: '

Added

', + section: 'Section A', + }); + + expect(mockClient.updatePage).toHaveBeenCalledWith(PAGE_ID, { html: expected }); + expect(result.section).toBe('Section A'); + }); + + it('fails with the available headings when the section is unknown', async () => { + mockClient.getPage.mockResolvedValue(page({ raw_html: '

Section A

Text

' })); + + await expect( + tool('bookstack_pages_append').handler({ + id: PAGE_ID, + content: '

x

', + section: 'No such section', + }) + ).rejects.toThrow(/Section not found/); + + expect(mockClient.updatePage).not.toHaveBeenCalled(); + }); + + it('writes nothing on a dry run', async () => { + mockClient.getPage.mockResolvedValue(page()); + + const result = await call('bookstack_pages_append', { + id: PAGE_ID, + content: '

New sentence

', + dry_run: true, + }); + + expect(mockClient.updatePage).not.toHaveBeenCalled(); + expect(result.written).toBe(false); + expect(result.dry_run).toBe(true); + }); + }); +});