wikijs-mcp-rag
README.md
[English](./README.md) | [Espanol](./README.es.md)
# wikijs-mcp-rag
**MCP + RAG** server for a private **Wiki.js** instance. Exposes the wiki's pages,
users and groups as MCP *tools* (CRUD) and adds a semantic search (RAG) layer over
the content: chunking + embeddings + vector index in SQLite (`sqlite-vec`) with
hybrid search (vector + lexical).
- **MCP transports:** `POST /mcp` (Streamable HTTP, stateless) and `GET /sse` + `POST /message` (SSE legacy).
- **Auth:** bearer token (`MCP_TOKEN`) on all MCP endpoints.
- **External endpoints:** the Wiki.js instance and the embeddings server (llama.cpp)
live outside the compose; they are configured via environment variables.
---
## Architecture
```
┌────────────────────────────────────────────────┐
MCP client │ wikijs-mcp-rag (Fastify) │
(OpenCode, llama.cpp │ │
UI, ...) │ ┌─────────────────────────────────────────┐ │
│ │ │ McpServer (22 tools) │ │
│ HTTP │ │ ┌───────────────┐ ┌────────────┐ │ │
├────────────────►│ │ │ Tools CRUD │ │ Tools RAG │ │ │
│ /mcp (bearer) │ │ │ pages(12) │ │ rag_search │ │ │
│ /sse (bearer) │ │ │ users(4) │ │ rag_get_ │ │ │
│ │ │ │ groups(1) │ │ context │ │ │
│ │ │ │ + ping │ │ rag_index_ │ │ │
│ │ │ └───────┬───────┘ │ status │ │ │
│ │ │ │ │ rag_reindex│ │ │
│ │ │ │ └─────┬──────┘ │ │
│ │ └───────────┼────────────────────┼────────┘ │
│ │ │ │ │
│ │ ┌───────────▼──────────┐ ┌──────▼──────────┐ │
│ │ │ WikiClient │ │ RagDb │ │
│ │ │ (GraphQL, bearer) │ │ SQLite+sqlite-vec │
│ │ └───────────┬──────────┘ └──────┬──────────┘ │
│ │ │ ▲ │
▼ │ │ │ │
┌──────────────┐ │ ┌──────────▼──────────┐ ┌──────┴──────────┐ │
│ Wiki.js │◄────────┘ │ Indexer/Querier │─►│ EmbeddingsClient│ │
│ (external) │ │ chunk+embed+store │ │ (llama.cpp) │ │
└──────────────┘ └─────────────────────┘ └──────┬──────────┘ │
▼ │
┌──────────────────┐ │
│ Embeddings (ext) │ │
└──────────────────┘ │
│
Background sync: CRUD hooks (SyncService) + Poller (5 min) + nightly Scheduler
```
---
## Available tools (22)
### Infrastructure (1)
| Tool | Description |
|---|---|
| `ping` | Health check. Returns `{ pong: true, timestamp }`. |
### Pages — CRUD (12)
| Tool | Description |
|---|---|
| `get_page` | Metadata of a page by `id` (no content). |
| `get_page_content` | Markdown content of a page by `id` (`{ title, content }`). |
| `list_pages` | Lists pages (`limit`, `orderBy`, `includeUnpublished`). |
| `search_pages` | Full-text search over published pages. |
| `create_page` | Creates and publishes a page in Markdown. |
| `update_page` | Updates fields of an existing page. |
| `delete_page` | Soft delete of a page. |
| `publish_page` | Publishes a page (`isPublished=true`). |
| `force_delete_page` | Permanent deletion (purge). Irreversible. |
| `get_page_status` | Status/metadata of a page. |
| `list_all_pages` | The whole corpus in a single request. |
| `search_unpublished_pages` | Filters unpublished pages (optional `query`). |
### Users (4)
| Tool | Description |
|---|---|
| `list_users` | Lists all users. |
| `search_users` | Searches users by name or email. |
| `create_user` | Creates a local user. |
| `update_user` | Updates fields of a user. |
### Groups (1)
| Tool | Description |
|---|---|
| `list_groups` | Lists all groups. |
### RAG (4)
| Tool | Description |
|---|---|
| `rag_search` | Hybrid semantic search (vector + lexical) over the RAG index. |
| `rag_get_context` | Assembled context block + `sources`, ready to hand to an LLM. |
| `rag_index_status` | Index status: pages, chunks, dims and last indexed. |
| `rag_reindex_page` | Re-indexes a specific page (rechunk + re-embed + store). |
---
## Configuration
All variables are validated in [`src/config.ts`](src/config.ts) with Zod.
Copy [`.env.example`](.env.example) to `.env` and fill in the **required** values.
| Variable | Required | Default | Description |
|---|---|---|---|
| `WIKIJS_BASE_URL` | no | `http://wikijs:3000` | Base URL of Wiki.js (actual endpoint `<base>/graphql`). |
| `WIKIJS_TOKEN` | no | `''` | Wiki.js admin token. Optional: empty ⇒ the client omits the `Authorization` header (instance without an API key). |
| `WIKIJS_INSECURE_TLS` | no | `true` | Accepts the proxy's self-signed TLS certificate. |
| `EMBEDDINGS_BASE_URL` | no | — | Base URL of the external embeddings server (`<base>/embeddings`). Optional: when unset the RAG tools return a clear "not configured" error and the rest of the server keeps working. |
| `EMBEDDINGS_API_KEY` | no | `no-key` | API key (llama.cpp ignores it). |
| `EMBEDDINGS_MODEL` | no | `Qwen3-Embedding-0.6B` | Model name (metadata). |
| `EMBEDDINGS_DIM` | no | `1024` | Embedding dimensionality. Changing it = reindex everything. |
| `MCP_TOKEN` | **yes*** | — | Bearer token for `/mcp`, `/sse`, `/message`. *Not required if `MCP_ALLOW_NOAUTH=true` (dev only). |
| `MCP_ALLOW_NOAUTH` | no | `false` | `true` + empty token → starts without auth with a warning. |
| `MCP_HOST` | no | `0.0.0.0` | Listen host. |
| `MCP_PORT` | no | `8000` | Listen port (published by Docker). |
| `RAG_DB_PATH` | no | `/data/rag.db` | Path of the SQLite file (must be on a volume). |
| `SYNC_POLL_INTERVAL_MS` | no | `300000` | Poller interval (ms). `0` = disabled. |
| `NIGHTLY_RESYNC_HOUR` | no | `3` | Local hour of the nightly reindex. |
| `NIGHTLY_RESYNC_ENABLED` | no | `true` | Enables/disables the nightly resync. |
| `LOG_LEVEL` | no | `info` | `debug` / `info` / `warn` / `error`. |
| `CHUNK_TARGET_TOKENS` | no | `800` | Target chunk size. |
| `CHUNK_OVERLAP_TOKENS` | no | `150` | Overlap between chunks. |
| `RAG_DEFAULT_TOP_K` | no | `5` | Default top-K of the RAG search. |
> **Index integrity:** if the SQLite file exists with an `embedding_dims` different
> from `EMBEDDINGS_DIM`, the process **does not start** and logs it (vector spaces are never mixed).
> Changing model/dims ⇒ delete the DB or reindex.
---
## How to run
### Option A — Published Docker image (GHCR, recommended)
Multi-arch image (`linux/amd64`, `linux/arm64`), built on every push to `main`:
```bash
docker pull ghcr.io/ricardoeplaza/wikijs-mcp-rag:latest
```
Minimal one-liner (standalone, no compose):
```bash
docker run -d --name wikijs-mcp-rag \
-p 8000:8000 \
-v wikijs-mcp-rag-data:/data \
-e MCP_TOKEN=change-me \
-e WIKIJS_BASE_URL=http://wiki:3000 \
ghcr.io/ricardoeplaza/wikijs-mcp-rag:latest
curl http://localhost:8000/health # => { "status":"ok" }
```
Drop-in service for an **existing Wiki.js docker-compose stack** (add to the
same `services:` block; `WIKIJS_BASE_URL` uses the wiki's service name):
```yaml
wikijs-mcp-rag:
image: ghcr.io/ricardoeplaza/wikijs-mcp-rag:latest
container_name: wikijs-mcp-rag
environment:
MCP_TOKEN: change-me # required (secure bearer)
WIKIJS_BASE_URL: http://wiki:3000 # service name of your wiki
WIKIJS_TOKEN: "" # only if the instance uses an API key
EMBEDDINGS_BASE_URL: http://embeddings:8071/v1 # optional; RAG tools disabled without it
ports:
- "8000:8000"
volumes:
- wikijs-mcp-rag-data:/data # SQLite persistence (chunks + vectors)
restart: unless-stopped
volumes:
wikijs-mcp-rag-data:
```
Notes:
- The container runs as the unprivileged `node` user; when it starts as root
(default) the entrypoint fixes ownership of the `/data` volume for you.
- Wiki.js and embeddings are **external**: make sure `WIKIJS_BASE_URL` and
`EMBEDDINGS_BASE_URL` point to services reachable from the container.
- The nightly resync uses the container's local time (UTC by default); set
`-e TZ=Europe/Madrid` if you want it in your timezone.
### Option B — Docker Compose from source
```bash
# 1. Configure the environment
cp .env.example .env
# -> fill in MCP_TOKEN (secure bearer); WIKIJS_TOKEN only if the instance uses an API key
# 2. Start the service (build + start)
docker compose up -d --build
# 3. Check health
curl http://localhost:8000/health # => { "status":"ok" }
```
- The SQLite database persists in `./data` (volume mounted at `/data`, `RAG_DB_PATH=/data/wikijs-rag.db`).
### Option C — Local development (no Docker)
```bash
npm install
cp .env.example .env # fill in the required values
npm run dev # tsx src/main.ts -> http://localhost:8000
```
Other scripts: `npm run build` (emits `dist/`), `npm start` (`node dist/main.js`),
`npm test`, `npm run lint`, `npm run typecheck`.
---
## Connecting an MCP client
### Streamable HTTP — `POST /mcp` (bearer)
Example MCP client configuration (`mcpServers` format):
```jsonc
{
"mcpServers": {
"wikijs": {
"url": "http://localhost:8000/mcp",
"headers": {
"Authorization": "Bearer <MCP_TOKEN>"
}
}
}
}
```
### SSE — `GET /sse` (legacy option)
For clients that only support SSE, use the `/sse` endpoint. Since `EventSource`
cannot send headers, the token is accepted as a query parameter:
```
http://localhost:8000/sse?token=<MCP_TOKEN>
```
(The client POSTs the messages to `POST /message?sessionId=...` with the same bearer.)
- No token or wrong token → `401 { "error": "unauthorized" }`.
- If `MCP_ALLOW_NOAUTH=true` and `MCP_TOKEN` is empty → accepted without auth (development only).
---
## RAG and synchronization
### Indexing
Each page is indexed as follows: Markdown content → **chunking** (`chunkMarkdown`:
sections by H1/H2, greedy packing, overlap) → **one batch call** to
`embeddings.embed()` → storage in SQLite (`pages` + `chunks` + vectors in
`chunks_vec` via `sqlite-vec`). The hash `sha256(content)` is stored to skip
unchanged pages.
### Hybrid search
`rag_search` / `rag_get_context` embed the query, retrieve candidates by KNN
vector search (`sqlite-vec`) and do a **hybrid rerank** per candidate:
`score = alpha·vecSim + (1−alpha)·lexical`, with `alpha=0.7` by default. Returns
the top-K by descending score.
### Sync mechanisms (3)
1. **CRUD hooks** (`SyncService`, fire-and-forget): after `create_page` /
`update_page` / `publish_page` → `reindexPage(id)`; after `delete_page` /
`force_delete_page` → `purgePage(id)`. They never block or break the tool response.
2. **Incremental poller** (every `SYNC_POLL_INTERVAL_MS`, 5 min by default):
reconciles index vs wiki — reindexes pages whose hash changed and purges those that no longer exist.
3. **Nightly scheduler** (at `NIGHTLY_RESYNC_HOUR`, 03:00 by default):
full `reindexAll()` as a final-consistency safety net.
### First-start reindex
On startup, `main()` calls `createApp(config, { startLifecycle: true })`, which
triggers `poller.start()`. This performs an **immediate `runOnce()`** (fire-and-forget) before
scheduling the interval. With an empty SQLite database, all wiki pages have a missing
hash → **all of them are indexed in that first sweep**. In other words, the **initial reindex
is automatic**: there is no need to invoke `reindex_all` manually; the poller's first `runOnce()`
indexes the whole corpus (and logs it). If you later change model/dims,
delete the DB (`./data`) or reindex with `rag_reindex_page` / a full reindex.
---
## Tests
```bash
npm test # vitest run (unit + integration)
npm run test:watch # watch mode
npx vitest run --coverage # with coverage
```
The tests do not start timers or real traffic: the app is built with
`startLifecycle:false` and the dependencies (WikiClient, embeddings, RagDb `:memory:`) are mocked.
---
## Project structure
```
src/
├── main.ts # bootstrap Fastify + lifecycle (poller/scheduler) + shutdown
├── config.ts # env variables (Zod) + loadConfig()
├── logger.ts # pino
├── server/
│ ├── mcp-server.ts # McpServer factory + ping + tools wiring
│ ├── http-transport.ts # POST /mcp (Streamable HTTP stateless)
│ ├── sse-transport.ts # GET /sse + POST /message
│ └── auth.ts # bearer middleware
├── tools/
│ ├── index.ts # registerAllTools (17 CRUD)
│ ├── pages.ts # 12 page tools
│ ├── users.ts # 4 user tools
│ ├── groups.ts # 1 group tool
│ └── rag.ts # 4 RAG tools
├── wiki/
│ ├── client.ts # WikiClient (GraphQL)
│ ├── queries.ts # GraphQL operations
│ └── types.ts # Zod schemas
└── rag/
├── db.ts # RagDb (SQLite + sqlite-vec)
├── embeddings.ts # EmbeddingsClient (llama.cpp, OpenAI-compatible)
├── chunker.ts # chunkMarkdown
├── indexer.ts # Indexer (indexPage / reindexAll / purgePage)
├── querier.ts # Querier (hybrid search + indexStatus)
├── sync.ts # SyncService (CRUD hooks fire-and-forget)
├── poller.ts # Poller (incremental resync)
└── scheduler.ts # Scheduler (nightly reindex)
```
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues