Analysis
Designing a Read-Only MCP Server for BookStack
A BookStack MCP server should begin read-only and treat local stdio and shared HTTP as different security models. Identity, context size, session cleanup, search failures, and write authorization need to be handled independently.

The current source, releases, issues, and installation instructions are public in ttpears/bookstack-mcp. The behavior discussed here is pinned to the 5.4.1 source revision. Check your installed version before applying these observations.
In this article
Searching and editing are different jobs
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 every user’s shared rate limit and permission ceiling.
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 rule independently.
Smaller responses still have to contain the answer
Tool definitions and wiki responses consume context before the assistant does much reasoning. Version 4 removed redundant fields, shortened descriptions, compacted JSON, and stopped fetching each search result again just to decorate it.
The May 23 release commit records live-BookStack measurements of a 32% reduction in tools/list, 55–57% for list endpoints, and 51% for get_recent_changes. Those are historical results from that workload, not promised savings for every wiki or a new benchmark of the current release.
Correction, October 10, 2026: The earlier table included exact before-and-after byte counts that I could not substantiate from the retained results. I removed those totals and retained only the reductions documented in the release commit.
The response changes were breaking changes: a client relying on a removed field needed updating even if the remaining data looked cleaner. Smaller JSON is not an improvement if the caller loses information it needs.
How to compare your own workload
The repository includes scripts/bench-context.mjs. It starts the built stdio server, initializes MCP, requests the tool list, and samples read tools. It reads the repository’s .env and inherited environment, so inspect the destination and use a deliberately limited test account before running it. It makes API requests; it is not an offline fixture.
- Fix the comparison: record both source revisions and builds, the same account permissions, query, result count, and unchanged test content. The script chooses the first page and book it finds; pin those IDs if their ordering can change.
- Check success first: reject JSON-RPC errors, MCP tool errors, denied requests, and missing results. The current script’s optional-field fallback can count a missing tool result as zero bytes. A shorter error is not a more efficient answer.
- Measure the same thing: the script counts UTF-8 bytes of serialized
tools/list.resultand the combined text content of each tool response. Those are different payload components, not total network traffic or tokenizer counts. Compare each operation with itself. - Check usefulness: inspect the returned fields and content, not just the totals. Keep timing and upstream request counts as separate measurements; fewer bytes do not automatically mean lower latency.
I reviewed this harness and the release record for this correction. I did not run a new benchmark against the private wiki.
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 cache moved to process scope, keyed by BookStack base URL. The next lookup after a ten-minute freshness interval can start another bulk sweep; this is not a background refresh timer. 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 per-BookStack-URL concurrency ceiling within that process.
Sharing a cache also shares assumptions
The cache key is the base URL, not the API credential. Two clients pointed at that URL reuse the same book-ID-to-slug map, even when their tokens differ. That saves requests, but it is not evidence that their access rights are interchangeable.
This is a metadata cache, not a page-body cache. Its existence alone does not demonstrate a bypass of BookStack’s page permissions. Before sharing a process across different permission scopes, check which metadata each role may learn. Cache by permission scope where that differs, or isolate the service instances; keep authorization on the actual API reads.
A useful regression fixture has two credentials with different book visibility. Warm the cache with one, then repeat permitted and denied lookups with the other, and reverse the order. Check returned metadata as well as HTTP status. Repeat after a permission change and a refresh interval. Those are proposed checks, not a test against a live private wiki performed for this update.
The refresh also updates existing map entries without first clearing them, and a caught warm-up failure still starts the freshness interval. A book that disappears from a later listing can therefore retain an old mapping. Ten minutes is not a guarantee that every cached entry is current or still visible to the caller. An offline adapter fixture against the pinned revision reproduced both behaviors: two clients with different synthetic credentials reused one sweep, and an empty listing after the freshness interval retained the earlier slug. A different base URL had a separate cache. This exercised metadata lookup, not page authorization or a private wiki.
Concurrency is not a rate quota
The semaphore limits requests in flight, not requests per minute. Four fast requests at a time can still exceed a server quota. The retry path must still handle 429 responses, and several MCP processes each have their own limiter. Capacity planning needs the combined request rate and retry behavior, not just the concurrency setting.
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.
Version 5.4.1 also fixes the response after a session disappears
Evicting an idle session solves the memory problem, but the client still holds its old session ID. A server restart has the same effect because these transport sessions live in process memory. The older HTTP handler returned 400 when that ID no longer existed.
The Streamable HTTP session contract requires 404 for a terminated session. On receiving that response to a request carrying its session ID, the client must initialize a new session without the old ID. Version 5.4.1 distinguishes that case from a non-initialization request that omits the required session ID, which still receives 400.
If a connector stops working after an idle period or deployment, inspect the response from the MCP endpoint. This server returns a JSON-RPC Session not found error for an unknown session. A proxy’s HTML 404 page or a misspelled endpoint is a different failure; repeatedly reconnecting will not fix the route. Do not log or publish full session IDs or authentication headers while diagnosing it.
The release’s HTTP tests cover initialization, unknown session IDs on POST, GET and DELETE, and missing-session requests. On October 10, I installed the pinned revision’s lockfile dependencies in an isolated checkout. All 23 tests passed on Node 24.19.0, including six HTTP session-contract checks against a loopback listener; the TypeScript check also passed. The HTTP fixture used synthetic credentials and did not invoke a BookStack tool. That verifies the server responses, not automatic reconnection in every client or a live Entra sign-in. The GitLab MCP session article describes the related failure and why session cleanup alone was insufficient.
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:
- The default tool list contains no write operations.
- The BookStack service account has its own limited role.
- Remote users authenticate through Entra ID.
- Only a Writer role receives the write-capable credential and write tools.
- The HTTP listener, host allowlist, reverse proxy, and container network define where the service can be reached.
- 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 has to know who is asking, what they can change, how much shared capacity they can consume, and what remains after they disconnect.
Disclosure: I maintain BookStack MCP.