wikijs-mcp
README.md
# wikijs-mcp (Tier 1)
Remote MCP server exposing **Wiki.js pages** (read/write markdown) to Claude. **Pages only — never
comments.** Comments are private user-to-user data and are unreachable by this server through two
independent layers.
## Part of a two-tier documentation system
This repo is **Tier 1** of a dual-purpose (human- and LLM-readable) docs setup, split across two
independent repos:
- **Tier 1 — this repo:** the remote MCP server that exposes Wiki.js **pages** as the global
knowledge base (knowledge broader than any single project).
- **Tier 2 — [`../claude-marketplace`](../claude-marketplace):** a companion Claude Code docs
plugin that enforces per-project local docs and bundles this MCP's config. That marketplace is
self-hosted and deployment-specific; this repo does not depend on it.
**Knowledge routing:** broader-than-project facts → global Wiki.js (this server); project-specific
detail → local `CLAUDE.md` / `docs/`.
## Comment isolation (two layers)
1. **By construction** — this server implements no comment queries/mutations; `pages.*` operations
never carry comment data.
2. **By auth** — the Wiki.js API key is bound to a dedicated **"MCP-bot" group** that has
`read/write/manage:pages` but **none** of `read/write/manage:comments`. Even a crafted request is
denied by Wiki.js.
> **Layer 2 only works if the key is actually bound to that group.** A key created with
> administrator scope ignores the group's permissions *and* its page rules entirely — it will happily
> write comments and reach paths the group was never granted. This is easy to miss because everything
> the MCP itself does keeps working, so nothing looks broken. Run the check under
> [Verify comment isolation](#verify-comment-isolation) after any key rotation; if a comment mutation
> **succeeds**, the key is over-scoped and only layer 1 is protecting you.
## Tools
| Tool | Purpose | Default |
|------|---------|---------|
| `wiki_search` | Full-text search → `totalHits`, `suggestions`, page summaries | on |
| `wiki_list` | List/browse pages (optional path prefix and tag filter) | on |
| `wiki_read` | Read a page's markdown by id or path | on |
| `wiki_create` | Create a markdown page | on |
| `wiki_update` | Update a page's markdown by id (partial; unset fields preserved) | on |
| `wiki_move` | Move/rename a page's path, keeping its history | on |
| `wiki_history` | List a page's revision trail | on |
| `wiki_version` | Read one historical version's content | on |
| `wiki_restore` | Roll a page back to a past version | **off** |
There is deliberately **no delete tool**. Removing a page is a human action, done in the Wiki.js UI.
Every tool in that table is subject to the [locale lock](#locale-lock) and the
[tag blocklist](#blocked-tags) below.
`wiki_search` repairs the ids it returns before handing them over. Under Wiki.js's `postgres` search
engine, results come from the `pagesVector` index table, which has its own serial `id` and no page-id
column — so the engine's `id` addresses a *different* page under `pages.single(id:)`. The server maps
each hit back to its real page id by path, which is the only key the two sides share. An id from
`wiki_search` is therefore safe to pass straight to `wiki_read`; a hit whose path is not in the
locked locale is dropped rather than guessed at.
Any tool can be withheld via `WIKIJS_DISABLED_TOOLS` (comma-separated). Withheld tools are never
registered, so they do not appear in `tools/list` at all — invisible to the model rather than
present-but-erroring. `wiki_restore` ships withheld because it overwrites live content wholesale.
## Locale lock
The server is pinned to exactly one locale, set by **`WIKIJS_LOCALE`** (required — there is no
default). **No tool exposes a `locale` field**, so the calling model cannot see that other locales
exist, let alone choose one. Pages in any other locale are invisible and unwritable here, including
by numeric id: `wiki_read` re-checks the locale of whatever the id resolved to and refuses a
mismatch.
This exists because a per-request `locale` argument (and a `WIKIJS_DEFAULT_LOCALE` that silently
defaulted to `en`) let agents build a second, parallel page tree in the wrong locale. Note this is a
*namespace* concern, not a language one — writing English prose into a `pl`-locale wiki is fine and
expected.
To repair an existing split, move every page from one locale to another (paths and content
untouched — nothing is translated):
```bash
# inside the container, or anywhere WIKIJS_URL/WIKIJS_API_TOKEN are set
node dist/migrate-locale.js --from en --to pl # dry run: lists moves, checks collisions
node dist/migrate-locale.js --from en --to pl --apply # perform them
```
It refuses to move anything if even one destination path is already taken, since a half-finished
migration is much harder to unpick than a refused one.
## Blocked tags
Some pages must never reach a model at all — local network topology and addresses, credential
inventories, the machine-readable input a security scanner consumes. **`WIKIJS_BLOCKED_TAGS`**
(comma-separated, **required** — there is no default) names the Wiki.js tags that withhold such a
page. Such a page is **announced but never served**:
- it still appears in `wiki_search` and `wiki_list` results — including when the caller filters for
exactly that tag — but as a **placeholder**: `withheld: true`, with its title and description
replaced by a notice saying the page is a security vault whose content will not be returned;
- **every** `wiki_read`, `wiki_create`, `wiki_update`, `wiki_move`, `wiki_history`, `wiki_version`
and `wiki_restore` touching it is **refused**, by id and by path alike, with that same notice.
Announcing beats hiding here. The content is unreachable either way, and a page that silently is not
there is exactly what sends an agent looking for the next route to it; a wall it can see makes it
report back to you instead.
The check lives in `src/wiki-client.ts`, beside the locale lock and for the same reason: enforcement
sits under every tool rather than inside each one, so a tool added later inherits it instead of
having to remember. It is code, not a prompt — the tool descriptions mention the rule only so the
model reports the refusal to you instead of hunting for a way around it.
**The tag is applied by hand, in the Wiki.js UI.** The MCP refuses to *apply* a blocked tag as well
as to honour one: `wiki_create`/`wiki_update` reject a tag set containing one. Blocking is a human
decision (the same reasoning that keeps back-marking off the tool surface), and this also means text
inside a page cannot talk an agent into hiding pages by tagging them.
`WIKIJS_BLOCKED_TAGS` is `required()` for a sharper reason than `WIKIJS_LOCALE` is. A default of
"block nothing" would mean one missing line in `.env` silently exposes every protected page, with
nothing appearing broken. Refusing to boot is the safe failure — so **adding this variable to an
existing deployment is a breaking config change: set it before you redeploy, or the container
crash-loops.** The active list is printed at startup; check the logs after a deploy to confirm the
tag there matches the tag on the pages.
Two things it deliberately does not do. Blocking is **per page, not per subtree** — a child of a
blocked page needs its own tag. And it does not apply to `mark-llm.js` or `migrate-locale.js`: those
are human-run CLIs that never hand content to a model, the same reason they are not MCP tools.
## LLM-generated marker
Every write through `wiki_create`, `wiki_update` (and after `wiki_restore`) is stamped, by the
server, as machine-written:
```markdown
<!-- wikijs-mcp:llm-generated -->
> 🤖 **Generated by an LLM.** Written by an AI agent through `wikijs-mcp` on 2026-08-28. Verify before relying on it.
{.is-warning}
<!-- /wikijs-mcp:llm-generated -->
```
plus an **`llm-generated`** tag, so every AI-written page can be listed at once
(`wiki_list` with `tags: ["llm-generated"]`).
The wording is a **compile-time constant in `src/marker.ts`** and the date comes from the server
clock. No tool input reaches it: the model cannot write it, reword it, omit it, or influence it —
that is the whole point. The `{.is-warning}` line is Wiki.js 2 blockquote-callout syntax and renders
as a warning box.
The HTML-comment sentinels make the stamp **idempotent**: a repeated write replaces the block rather
than stacking banners, and refreshes the date. If someone hand-deletes the banner, the next write
puts it back.
### Back-marking pages written before this existed
Pages an LLM wrote *before* the stamp landed carry no marker, and Wiki.js cannot tell you which
those are — every page shows `creatorId = 1` because the API key authenticates as the admin user.
A worksheet records the verdict per page (`[x]` written through this server, `[p]` generated in
a chat and pasted in by hand, `[a]` written by a scheduled job, `[ ]` human, `[?]` undecided), and
a one-shot script applies it. Copy [`LLM-AUDIT.example.md`](LLM-AUDIT.example.md) to
`LLM-AUDIT.md` and fill it in — a filled-in worksheet quotes real page paths and opening lines out
of your wiki, so `LLM-AUDIT.md` is gitignored and never committed:
```bash
docker compose exec -T wikijs-mcp node dist/mark-llm.js --file - < LLM-AUDIT.md # dry run
docker compose exec -T wikijs-mcp node dist/mark-llm.js --file - --apply < LLM-AUDIT.md
```
A back-marked banner carries the page's **own creation date**, not today's. `[p]` pages get wording
that does not claim `wikijs-mcp` authorship, and `[a]` pages get a different banner altogether —
generated by automation, edits may be overwritten — plus the `automated` tag rather than
`llm-generated`, because a CI job is not an LLM. The script is idempotent (an already-correct
page is reported `unchanged` and not rewritten) and reversible (flipping a row to `[ ]` strips the
banner and the tag, restoring the body byte-for-byte).
Like the locale migration, this is **not** an MCP tool: deciding what an LLM wrote is a human
judgement, and a model that could add or remove its own attribution would defeat the point.
## One-time Wiki.js setup
1. **Administration → Groups → New Group** → name it `MCP-bot`.
2. **Global permissions:** enable `read:pages`, `write:pages`, `manage:pages`. **Do NOT enable** any
`*:comments` permission.
3. **Page Rules** (this is required, not optional — see the Wiki.js `pages.single` quirk, GitHub
#6216): add a rule granting `read`, `write`, `manage` on path `` (empty = all paths) or scope it to
the paths the bot should manage (e.g. `infra`). Without a matching page rule, `wiki_read` returns
"not authorized" even with the global permission set.
4. **Administration → API** → enable the API, **Create New Key**, assign it to the `MCP-bot` group,
copy the token into `WIKIJS_API_TOKEN`.
## Configure & run
```bash
cp .env.example .env # fill in WIKIJS_URL, WIKIJS_API_TOKEN, WIKIJS_LOCALE,
# WIKIJS_BLOCKED_TAGS, MCP_BEARER_TOKEN
# local dev
npm install && npm run dev
# or containerized, beside Wiki.js
docker compose up -d --build
```
The MCP endpoint is `POST/GET/DELETE /mcp` and requires `Authorization: Bearer $MCP_BEARER_TOKEN`.
Put it behind your reverse proxy with TLS; never expose it raw.
## Smoke test (before wiring Claude)
```bash
# initialize handshake (expect a JSON-RPC result, not 401)
curl -sS -X POST https://wiki-mcp.example.com/mcp \
-H "Authorization: Bearer $MCP_BEARER_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
```
## Add to Claude Code
Two routes. Pick **one** — installing the plugin while a manual `wikijs` entry exists gives you the
server twice.
### Route A — directly (this server alone)
```bash
claude mcp add --transport http --scope user wikijs https://wiki-mcp.example.com/mcp \
--header "Authorization: Bearer $MCP_BEARER_TOKEN"
```
`--scope user` registers it across all your projects, which is what you want for a *global* knowledge
base. Use `--scope local` to limit it to the current project, or `--scope project` to write a shared
`.mcp.json` into the repo — but note that scope commits the file, so keep the token in an env var
rather than inlining it.
Verify from inside Claude Code:
```
/mcp
```
Expect `wikijs` listed as **connected**, with the `wiki_*` tools (nine, minus anything in
`WIKIJS_DISABLED_TOOLS`). To remove it:
`claude mcp remove wikijs`.
### Route B — via the Tier 2 docs plugin (recommended)
This is the intended path: the plugin bundles the same MCP config *and* the per-project doc
enforcement, so the two tiers arrive together. The marketplace and plugin names below are
placeholders — substitute your own, since Tier 2 is self-hosted per deployment.
```bash
export WIKI_MCP_URL=https://wiki-mcp.example.com/mcp
export WIKI_MCP_TOKEN=<your MCP_BEARER_TOKEN>
```
Put those in your shell profile — the plugin's `.mcp.json` interpolates them at load time, and
Claude Code will not resolve them if they're only set in a subshell. (Match the variable names to
whatever your plugin's `.mcp.json` actually reads.) Then, inside Claude Code:
```
/plugin marketplace add https://git.example.com/<org>/claude-marketplace.git
/plugin install <docs-plugin>@<marketplace>
```
Or, if you have the repo cloned beside this one, point at the working copy instead:
`/plugin marketplace add ../claude-marketplace`. Restart the session and confirm with `/mcp`.
> A bare `owner/repo` here would resolve against **GitHub** — this marketplace is self-hosted Gitea,
> so always pass the full URL or a path.
### If it doesn't connect
- **401** — token mismatch between the client header and the server's `MCP_BEARER_TOKEN`.
- **Connected, but `wiki_read` says "not authorized"** — missing Wiki.js *page rule*, not a Claude
problem. See step 3 of the one-time setup above.
- **TLS errors against a `.lan` host** — Claude Code will not accept a self-signed cert; terminate
TLS at a reverse proxy with a cert your machine trusts.
## Verify comment isolation
With the same API key, run a comment mutation directly against Wiki.js GraphQL and confirm it is
**denied** — proving the group-permission layer:
```graphql
mutation { comments { create(pageId: 1, replyTo: 0, content: "x") { responseResult { succeeded message } } } }
```
Expect `succeeded: false` / an authorization error. (The MCP itself exposes no such tool at all.)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessUnresponsive