Skip to content
Hack Your WorldHome Assistant · lighting · home projects

Workbench

Why My BookStack MCP Server Starts Read-Only

BookStack MCP project artwork describing a Model Context Protocol server for BookStack
Project artwork from the BookStack MCP repository.

I built a BookStack MCP server so an assistant could use the documentation where the conversation was already happening. The easy version worked over stdio. The useful version needed HTTP, OAuth, shared credentials, bounded sessions, and a much smaller appetite for context.

The current source, releases, issues, and installation instructions are public in ttpears/bookstack-mcp. This article is the design story behind them.

The first useful boundary was read versus write

Searching a wiki and changing a wiki are different jobs. A client that needs answers from BookStack does not automatically need permission to edit pages, move chapters, empty the recycle bin, or permanently delete anything.

So the local server registers read tools by default and does not register write tools unless BOOKSTACK_ENABLE_WRITE=true. The difference is visible to the assistant at tool-discovery time. It is not a sentence in a system prompt asking the model to behave.

BOOKSTACK_BASE_URL=https://wiki.example.com
BOOKSTACK_TOKEN_ID=read-token-id
BOOKSTACK_TOKEN_SECRET=read-token-secret
BOOKSTACK_ENABLE_WRITE=false

I still give the BookStack service account only the permissions it needs. The environment flag controls which MCP tools exist; the BookStack role is the backstop if that application-level gate ever fails.

Local stdio and shared HTTP are different security models

For one person on one machine, stdio is clean: the assistant launches npx bookstack-mcp, credentials stay in that user’s environment, and no network listener exists.

A shared LibreChat deployment or remote connector changes the question. The MCP server becomes a long-running service. Multiple people create sessions. A reverse proxy may sit in front of it. One shared BookStack token can become the rate-limit and permission boundary for everyone.

The HTTP listener therefore binds to loopback by default. Binding it to a non-loopback address requires an explicit host allowlist for DNS-rebinding protection. An internal unauthenticated deployment belongs on a trusted container network with no published host port.

For a public connector, I split identity from BookStack credentials

The remote connector uses Microsoft Entra ID for the person signing in, while the server keeps the BookStack credentials. Users never paste a BookStack token into the assistant.

There are two BookStack service credentials:

  • A read-only token used for ordinary sessions.
  • A write-capable token used only when the validated Entra token contains the configured Writer app role.

The important part is what happens after authentication. A non-writer session is physically bound to the read-only BookStack credential. If a later tool-registration mistake exposes a write-shaped tool, the credential still cannot perform the write.

That is stronger than checking a role only at the final button press. Identity decides which credential and tool set the session receives; BookStack permissions enforce the same boundary independently.

The server was wasting context before the user asked anything

MCP tool definitions and responses compete with the user’s question for context. In version 4, I measured the payloads against a live BookStack and removed fields that were redundant, decorative, or already available elsewhere.

Measured JSON payload sizes before and after the version 4 cleanup
Request Before After Reduction
tools/list 13,636 bytes 9,290 bytes 32%
search_content, five results 17,735 bytes 10,985 bytes 38%
get_books, five results 3,636 bytes 1,570 bytes 57%
get_recent_changes, five results 19,302 bytes 9,519 bytes 51%

The reductions came from compact JSON, shorter tool descriptions, removing friendly date duplicates, dropping a redundant capabilities tool, and returning search data directly instead of fetching every result again. Single-page reads improved less because the page content—not metadata—dominates that response.

This was a breaking release because smaller responses are not useful if clients silently depend on the removed fields. The benchmark script stayed in the repository so the payload can be measured again instead of remembered as folklore.

Then a search request crashed the container

The nastiest failure arrived after the server was shared. Search results needed book slugs, and the enhancement path resolved those slugs item by item. Several uncached results could stampede BookStack with requests at once.

The shared service token had a 180-request-per-minute limit. BookStack returned 429 responses, retries honored delays between 12 and 47 seconds, pending work accumulated, and the Node process eventually hit its heap limit and exited with code 134.

The first fix replaced the per-item fetches with one bulk book listing, an in-flight promise so concurrent callers shared the same warm-up, and a request timeout. But the cache still lived on each client object, and the server creates a client for every MCP session. New sessions therefore repeated the same warm-up and burned the same shared rate-limit budget.

The final cache moved to process scope, keyed by BookStack base URL, with a ten-minute refresh. A test using two separate client instances then produced one book-list sweep instead of two. The next release added a configurable semaphore so all remaining BookStack requests respect a process-wide concurrency ceiling.

Closing a browser tab is not a cleanup strategy

HTTP sessions originally left the registry only when the transport’s close callback ran. A vanished client or broken network connection did not always close cleanly. The abandoned entry kept its transport, MCP server, and tool closures alive indefinitely.

Version 5.4 extracted session tracking into a registry with two independent limits:

  • A 30-minute idle-session timeout with periodic and on-access cleanup.
  • A default hard cap of 1,000 concurrent sessions.

The health endpoint now reports the active session count and process RSS memory. The unit tests reproduce abandoned sessions and verify that eviction closes the transport before removing it. I have not labeled that fix “proven in production” because the pull request was unit-tested but not yet field-tested under a real abandoned-connection workload.

A page with 6,393 characters was reporting zero words

BookStack populates different content fields depending on whether a page was written in Markdown or the WYSIWYG editor. The word-count helper used the plain-text field, so a WYSIWYG page could contain thousands of characters and still report word_count: 0.

The fix counts the content that was actually resolved: plain text first, then Markdown, then tag-stripped HTML. It also splits on whitespace rather than a literal space so newline-separated Markdown does not become one enormous “word.”

That bug also exposed a permissions trap. Recovering Markdown for WYSIWYG pages uses BookStack’s export endpoint, so a read-only service role still needs the separate Export Content permission. Without it, a page can look empty even though ordinary viewing works.

What “read-only by default” means now

It is a stack of controls, not one boolean:

  1. The default tool list contains no write operations.
  2. The BookStack service account has its own limited role.
  3. Remote users authenticate through Entra ID.
  4. Only a Writer role receives the write-capable credential and write tools.
  5. The HTTP listener, host allowlist, reverse proxy, and container network define where the service can be reached.
  6. Concurrency, session lifetime, and session count are bounded even when clients behave badly.

I started the project because documentation is more useful when an assistant can retrieve it during the task. The production lesson was that “give the assistant access to the wiki” is not one integration. It is a set of boundaries around who is asking, what they can change, how much shared capacity they can consume, and what the server retains after they disappear.

Disclosure: I maintain BookStack MCP. It is open-source software under the MIT license. The repository and npm links are not affiliate links.