2.0.0 (2026-07-17)
⚠ BREAKING CHANGES
- Bun is now required. Node.js is no longer supported: the
binstarts with
#!/usr/bin/env bunand the package ships TypeScript source rather than a bundle.
This release modernizes the toolchain around Bun, drops the Node.js runtime,
and closes a number of correctness and security defects.
Breaking changes — read these before upgrading:
- The package runs on Bun and no longer ships a Node-runnable
dist/build.
npx/npm install -gwill not work; usebunx/bun add -g. - The HTTP transport now requires
MCP_AUTH_TOKENand refuses to start
without it.POST /messagedispatches every tool — including permanent
deletes and user/role operations — using your BookStack admin credential, and
previously accepted them from anyone who could reach the port. The stdio
transport is unaffected. - Validation is strict by default (
VALIDATION_STRICT_MODE=true). Invalid
tool parameters are now rejected at the boundary instead of being logged and
forwarded to BookStack. Set it tofalsefor the old permissive behaviour. bookstack_images_createnow requiresuploaded_to— it was silently
stripped before, so the call could never have succeeded.
Added
- HTTP transport now exposes
GET /(server info) andGET /health(returns
200when healthy,503otherwise), plus a JSON404handler for unknown
routes. This makes the endpoints described in the setup guide actually work
and lets container/orchestrator health checks succeed. - Inbound authentication for the HTTP transport (
MCP_AUTH_TOKEN), compared
in constant time and checked before the request body is parsed. The
transport fails closed: there is no "no auth" mode. - A configurable request body limit (
HTTP_BODY_LIMIT, default 70 MiB).
Express's ~100 KB default silently capped inline uploads at roughly 75 KB and
answered anything larger with a413raised before MCP dispatch — while the
tools advertised a limit 600× higher. GET /healthnow coalesces concurrent probes into a single upstream check
(5s TTL, bounded waiters), so unauthenticated readiness traffic cannot crowd
out authenticated tool calls or spend the BookStack budget without limit.- Multi-stage Bun
Dockerfile(oven/bunbase) that runs the TypeScript
source directly, with a/health-basedHEALTHCHECK. docker-compose.ymlrunning BookStack + MariaDB + the MCP server for
real-world local testing.- Integration test suite that runs against a real BookStack instance, with an
API token provisioned automatically (BookStack normally only allows creating
tokens through its web UI). A plainbun testskips the live suite when
BookStack isn't reachable, so it passes without Docker;bun run test:integration
setsRUN_INTEGRATION=1and fails loudly if the stack is missing, so an
explicit integration run can never pass vacuously. See
docs/integration-testing.md. - GitHub Actions CI running typecheck, tests, and lint on push and pull request.
CHANGELOG.md(this file).
Changed
- Runtime & tooling migrated from Node.js to Bun: execution, package
manager (bun.lockreplacespackage-lock.json), and the test runner
(bun test). The package is now ESM ("type": "module") and the entry point
usesimport.meta.main. - Linting/formatting switched from ESLint + Prettier to Biome. The
previousnpm run lintwas broken (no ESLint config); Biome now lints and
formats viabun run lint/bun run format. - All dependencies upgraded to their latest versions, including TypeScript
7, Zod 4, Express 5, and@modelcontextprotocol/sdk1.29.tsconfig.json
updated for TypeScript 7 (moduleResolution: bundler, removedbaseUrl). - All
anytypes across the tool/resource handlers replaced withunknownat
the dynamic boundary and the concrete param interfaces fromsrc/types.ts. LOG_LEVELandLOG_FORMATnow genuinely drive the logger through config.
Previously the logger read the environment directly and ignored the validated
config, soLOG_FORMAThad no effect and an invalidLOG_LEVELpassed
silently. Invalid values are now rejected at startup.VALIDATION_STRICT_MODEnow defaults totrue. Previously validation was
advisory: a schema violation was logged as a warning and the original
parameters were forwarded to BookStack anyway. Invalid parameters are now
rejected at the boundary with a clear error. SetVALIDATION_STRICT_MODE=false
to restore the permissive behaviour.- The recycle-bin restore and permanent-delete tools now report
restore_count/
delete_count. The recycle bin is top-level only — deleting a book with a
chapter and a page creates one entry, and restoring it brings back all
three — so the count is the only signal of how much was affected. It was
previously discarded.
Removed
- Node.js support and the
tscbuild step. The package no longer publishes
a compileddist/for Node consumers; it runs on Bun. - Build output (
dist/) is no longer tracked in git. - Unused dependencies:
compression,cors,helmet,uuid, andsupertest
(the first three were configured but never wired into the Express app). - Node-only dev dependencies:
jest,ts-jest,ts-node,nodemon,
rimraf, and their@types.
Fixed
- Health/root endpoints returning
404under the HTTP transport. dotenvprinted an "injected env" banner to stdout, which corrupted the
JSON-RPC stream under the stdio transport. It is now silenced: in stdio mode
stdout carries only MCP protocol messages — no banner and no log lines
(all logging goes to stderr).- The Claude Desktop integration examples omitted
MCP_TRANSPORT=stdio. Because
the transport defaults tohttp, following the documented config started an
HTTP server that Claude could not connect to. - Documentation described features that do not exist: a
config.jsonfile,
retryAttempts/retryDelay, afeaturesblock,ENABLE_METRICS/
METRICS_PORT/HEALTH_CHECK_PORT, a multi-instanceinstancesarray, a
--versionflag, and a log file at/var/log/bookstack-mcp.log(the logger
only ever wrote to stderr). All removed. - Zod 4 renamed
ZodError.errorstoZodError.issues. The error handler still
read.errors, so any Zod validation error reaching it crashed with
TypeError: Cannot read properties of undefined(reachable via strict
validation mode). Both the error handler and config validation now read
.issues. - The API client documented "retry logic" but never retried —
isRetryable()
was dead code with no callers, so BookStack's rate limit (180 requests/minute
per user) surfaced as a hard failure. Requests now genuinely retry transient
failures, honouringRetry-After/X-RateLimit-Reset, bounded by an attempt
and total-wait budget.429retries any verb (the request was rejected before
executing);5xxonly retries idempotent verbs. - The six export tools declared they return
{content, filename, mime_type}but
actually returned the raw response body, so.contentwas alwaysundefined.
PDF exports were additionally corrupted by text-decoding binary data (an 879KB
PDF inflated to 1.5MB of replacement characters). Exports now return a
populated result, withencodingandbyte_lengthso binary content is
unambiguous. parseIntcalls now pass an explicit radix.
Tools that silently returned the wrong results
pages_list'sdraftandtemplatefilters returned the opposite of what
was asked. The boolean was serialized as the string"true", which MySQL
coerces to0against BookStack'stinyintcolumns — sodraft: true
matcheddraft = 0and returned non-drafts. No error was raised.
roles_list'smfa_enforcedfilter had the identical bug.search's advertised{tag:name=value}syntax does not exist. BookStack
silently drops an unrecognised{filter:…}term rather than erroring, so the
query degraded to match-all — the documented example{tag:status=active}
returned every item while appearing to filter. The real syntax is
[name=value]. Likewise{type:shelf}matched nothing (the token is
bookshelf).audit_log_listsorted oldest-first while documenting "most recent first".namefilters are exact-match, not partial, on books, chapters, pages,
images, attachments and shelves — all six documented "partial match", so a
search for a fragment returned nothing.
Tools that never worked
usage_examplesreturned{error: 'Workflow not found'}for every value it
advertised — the lookup matched against titles, and no title contained the
underscore its enum values use.helpadvertised six topics but implemented three; the other three
returnedguidance: undefined. Its contextual advice recommended
bookstack_search_all, which does not exist.images_createcould never succeed:uploaded_tois required by BookStack
but was silently stripped by validation.
Parameters that were silently discarded
roles_list.filter,users_create/users_update.external_auth_id,
images_list.filter.uploaded_to,chapters_list.filter.created_byand
audit_log_list's date filters were all advertised and supported by BookStack,
but omitted from the validation schemas — so they were stripped, and callers
received unfiltered results that looked like successful queries.users.emailandusers.external_auth_idwere silently truncated at 191
characters (the underlying column limit) with no error — an over-long email
lost its domain. Both are now rejected at the boundary.
Capabilities and parameters that did not exist
users_update.active— BookStack has no such field or column. The call
succeeded and did nothing, while the tool's own example was "Deactivate a
user". Removed; the description now explains the real alternative.roles_delete.migrate_ownership_id— BookStack's role-delete accepts no
body, so the user was left with no roles rather than migrated. Removed.
(users_delete.migrate_ownership_idis real and was kept.)roles_create/roles_update.permissionsadvertised an object of booleans;
BookStack requires an array of strings, so the documented example always
returned422.server_infoadvertised resource URIs that could never resolve, and
reportedsupports_batch_operations: trueandsupports_caching: truefor
features that do not exist.tool_categoriesomitted 5 of the 56 tools.- Advertised length limits contradicted BookStack's real ones (role description
1000 vs 180; image name 255 vs 180; user name 255 vs 100), guaranteeing a
422at the boundary values the tools claimed to accept.
Logging
- Plaintext passwords, page content and whole upload payloads were written to
the logs at the default level. The tool boundary logged its raw arguments, so
creating a user wrote the new account's password, and an image upload
duplicated its entire base64 payload into container logs. Log metadata is now
an allowlist: a string is withheld unless its key proves it safe, and a key
naming a credential is never rendered at all — not even its length. Search
queries, names and emails are reported as lengths. - URLs are sanitized structurally wherever they appear (userinfo removed,
credential-bearing query values replaced), rather than by matching key names —
a URL carries its credentials inside the value, where no key rule can see
them. - A rejected
BOOKSTACK_BASE_URLis no longer quoted back in the error. It named
the setting and the offending component, but a base URL's path is arbitrary
operator text that can carry a proxy capability. dotenvand a strayconsole.logwrote to stdout, which corrupts the
JSON-RPC stream under the stdio transport. Nothing in the process writes to
stdout now except MCP protocol messages.
Rate limiting
RATE_LIMIT_*never bound on the HTTP transport. EveryPOST /message
built a new client with a fresh token bucket, so each request received a fresh
burst allowance. The limiter is now shared per outbound identity (base URL +
token), so the configured budget is a real ceiling. Equivalent spellings of the
same URL resolve to one bucket; distinct credentials stay isolated.
Response types that described fields BookStack does not return
SystemInfodeclared eight fields (php_version,theme,timezone, …) that
BookStack does not return;ContentPermissions,AuditLogEntryand
RecycleBinItemall had wrong field names;tagswas declared always-present
but is absent from every list response. The API client casts responses without
checking, so none of this failed loudly — it surfaced asundefined.validation.enabledusedconfig.validation?.enabled || true, which can never
befalse, so the server could not report validation as disabled.