Преглед изворни кода

Rewrite WikiJS MCP server in Go

- Implement a typed WikiJS GraphQL client and MCP tools
- Add stdio and Streamable HTTP transports
- Require bearer auth for non-loopback HTTP, default to loopback
- Load .env with --env-file/ENV_FILE precedence
- Include tests, CI, Docker setup, and documentation
Paul Klumpp пре 1 недеља
родитељ
комит
455258ef04

+ 32 - 0
.github/workflows/ci.yml

@@ -0,0 +1,32 @@
+name: CI
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+
+permissions:
+  contents: read
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: actions/setup-go@v5
+        with:
+          go-version-file: go.mod
+          cache: true
+
+      - name: Check formatting
+        run: test -z "$(gofmt -l .)"
+
+      - name: Vet
+        run: go vet ./...
+
+      - name: Test
+        run: go test -race ./...
+
+      - name: Build
+        run: go build ./...

+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+/bin/
+.env
+coverage.out
+*.test

+ 213 - 0
AGENTS.md

@@ -0,0 +1,213 @@
+# AGENTS.md
+
+Guidance for coding agents working on this repository.
+
+## What this project is
+
+A Go implementation of a Model Context Protocol (MCP) server for WikiJS. It is
+a rewrite of the TypeScript package
+[`@cahaseler/wikijs-mcp`](https://github.com/cahaseler/wikijs-mcp). The public
+tool surface is compatible with that server; no Node.js, npm, or GraphQL
+codegen is involved.
+
+The server exposes two transports:
+
+- **stdio** (default): the client launches the binary and speaks JSON-RPC over
+  stdin/stdout.
+- **streamable HTTP**: `--transport http`, serving `/mcp` and `/healthz`.
+
+## Commands
+
+```bash
+make build       # bin/wikijs-mcp, version injected from git describe
+make test        # go test ./...
+make test-race   # go test -race ./...
+make vet         # go vet ./...
+make fmt         # gofmt -w .
+make check       # vet + race tests
+make run         # stdio transport (needs WIKIJS_URL/WIKIJS_API_KEY)
+make run-http    # HTTP transport on 127.0.0.1:8080
+make docker-up   # local WikiJS on http://localhost:3000
+```
+
+### Formatting and imports (gofmt + gopls)
+
+Always format Go files with `gofmt`. Use `gopls` on top of it for import
+tidying, canonical formatting, and diagnostics. Run both on the files you
+changed:
+
+```bash
+gofmt -w path/to/file.go           # standard formatting
+gopls imports -w path/to/file.go   # add/remove/sort imports
+gopls format -w path/to/file.go    # canonical formatting
+gopls check path/to/file.go        # type/lint diagnostics
+```
+
+`make fmt` runs `gofmt -w .`; CI enforces `gofmt -l .`.
+
+Before finishing a change, run at minimum:
+
+```bash
+gofmt -w <changed-go-files>
+gopls imports -w <changed-go-files>
+gopls format -w <changed-go-files>
+gopls check <changed-go-files>
+gofmt -l .        # must print nothing
+go vet ./...
+go test -race ./...
+go build ./...
+```
+
+## Layout
+
+```
+cmd/wikijs-mcp/main.go        flags, env wiring, logger, signals, transport selection
+internal/config/              environment and .env parsing, validation
+  config.go                   Config, defaults, validation
+  dotenv.go                   optional .env loading
+internal/wikijs/              typed WikiJS GraphQL client (stdlib HTTP only)
+  types.go                    API and output types
+  queries.go                  GraphQL documents as raw string constants
+  client.go                   request/response handling, URL enrichment, errors
+internal/server/              MCP server construction and tool handlers
+  server.go                   mcp.Server, tool metadata, edit gate
+  tools.go                    input structs, validation, handlers
+internal/transport/           streamable HTTP handler, bearer auth, graceful shutdown
+```
+
+Data flow:
+
+```
+MCP client ── stdio / HTTP ──▶ mcp.Server ──▶ tool handler ──▶ wikijs.Client ──▶ WikiJS /graphql
+```
+
+## Invariants
+
+- **stdout is protocol-only.** With stdio, stdout carries MCP JSON-RPC. All
+  logging must go to stderr (`slog` is configured that way). Never use `fmt.Print`
+  or `log.Print` for diagnostics.
+- **Tool names and argument names are a public API.** Keep them stable:
+  `search_pages`, `get_page_by_id`, `get_page_by_path`, `get_all_pages`,
+  `create_page`, `edit_page`; camelCase arguments such as `isPublished`.
+- **Write tools are opt-in.** `create_page` and `edit_page` are registered only
+  when `WIKIJS_ENABLE_EDIT=true`.
+- **URLs are always absolute.** `wikijs.Client` appends `url` to pages, list
+  items, and search hits. The locale segment is included only for non-English
+  pages (`/de/path`, but `/path` for `en`).
+- **Secrets stay out of flags and logs.** `WIKIJS_API_KEY` and `MCP_AUTH_TOKEN`
+  come from the environment or the env file. Request bodies and headers are
+  never logged.
+- **Environment first, `.env` optional.** `--env-file` (scanned by
+  `envFileFromArgs` before flag parsing) wins over `ENV_FILE`, which wins over
+  `./.env`. `config.LoadDotEnv()`/`LoadEnvFile()` never override existing
+  process variables. A missing default file is ignored, an explicit one must
+  exist. Document new variables in `README.md` and `env.example`.
+- **HTTP is fail-closed without auth.** `config.Validate()` rejects an HTTP
+  listen address that is not loopback unless `MCP_AUTH_TOKEN` is set. Keep the
+  loopback default (`127.0.0.1:8080`) and require a token for `:8080` or
+  `0.0.0.0:8080`.
+- **Errors are tool errors.** Handlers return Go errors; the MCP SDK marks the
+  result with `isError` and puts the message in the content so the model can
+  react.
+
+## Tool handler conventions
+
+Each handler uses the generic `mcp.AddTool` and a typed input struct:
+
+```go
+type exampleInput struct {
+    Required string `json:"required" jsonschema:"description for the model"`
+    Optional string `json:"optional,omitempty" jsonschema:"optional value"`
+}
+
+func (h *handlers) example(ctx context.Context, _ *mcp.CallToolRequest, in exampleInput) (*mcp.CallToolResult, someOutput, error) {
+    if err := validate...; err != nil {
+        return nil, someOutput{}, err
+    }
+    return nil, result, nil // SDK fills structuredContent and JSON text content
+}
+```
+
+Rules:
+
+- Fields without `omitempty` are required and appear in `required` in the input
+  schema.
+- Use pointers (`*int`, `*bool`) for optional values with defaults so "unset"
+  and "zero" remain distinguishable. Apply defaults in the handler.
+- Validate semantic constraints in the handler (positive IDs, locale syntax,
+  non-empty paths). The SDK validates JSON types and required fields.
+- Return `nil` for `*mcp.CallToolResult` when the SDK should serialize the
+  output. Use `nullResult()` only for nullable results: a typed nil pointer
+  would otherwise be coerced to an empty object by the SDK's output schema
+  handling.
+- Register the tool in `internal/server/server.go` with a description and
+  annotations (`ReadOnlyHint` for reads).
+
+### Adding a tool
+
+1. Add an input struct and handler in `internal/server/tools.go`.
+2. If it needs a new WikiJS operation, add the document to
+   `internal/wikijs/queries.go`, a typed method to `client.go`, and tests.
+3. Register the tool in `server.go` (inside the edit gate if it mutates).
+4. Add a handler test in `internal/server/tools_test.go` and, for HTTP, a
+   transport test if relevant.
+5. Document the tool in `README.md`.
+
+## WikiJS GraphQL notes
+
+- Endpoint: `<WIKIJS_URL>/graphql` with `Authorization: Bearer <api key>`.
+- `WIKIJS_URL` may already end in `/graphql` or `/`; `New()` normalizes both.
+- Requests are plain JSON (`query` + `variables`). Keep optional variables
+  absent rather than sending `null`/empty strings.
+- GraphQL errors arrive with HTTP 200 in `errors[]`; `APIError` aggregates their
+  messages. HTTP failures include the status and a truncated body.
+- Mutations return `responseResult { succeeded errorCode slug message }`.
+  Successful transport does not imply a successful mutation: check `Succeeded`.
+- `list` returns `tags` as `[]string`; `single`/`singleByPath` return
+  `tags { tag title }`. The types reflect this difference.
+- Timestamps (`createdAt`, `updatedAt`) are kept as strings to preserve WikiJS'
+  format and avoid time zone/layout assumptions.
+- Pages may not exist: `single` and `singleByPath` are nullable. Return a nil
+  page and let the handler emit `null`.
+
+## Testing strategy
+
+- `internal/wikijs`: `httptest` fake GraphQL server. Assert auth header,
+  variables, normalization, URL building, GraphQL errors, and HTTP errors.
+- `internal/server`: real MCP client over `mcp.NewInMemoryTransports()` and a
+  fake WikiJS. Assert tool visibility, structured output, defaults, validation,
+  and `isError` behavior.
+- `internal/transport`: `httptest` for `/healthz`, bearer auth (401 and 200),
+  and a full streamable-HTTP MCP roundtrip.
+- Prefer table-driven tests where inputs vary; keep fake responses minimal.
+
+## Configuration reference
+
+| Variable | Default | Notes |
+|----------|---------|-------|
+| `WIKIJS_URL` | – | Required; absolute http(s) URL |
+| `WIKIJS_API_KEY` | – | Required |
+| `WIKIJS_ENABLE_EDIT` | `false` | Registers write tools |
+| `WIKIJS_TIMEOUT` | `30s` | Per-request HTTP timeout |
+| `MCP_TRANSPORT` | `stdio` | `stdio` or `http` |
+| `MCP_ADDR` | `127.0.0.1:8080` | HTTP listen address |
+| `MCP_PATH` | `/mcp` | Must start with `/`, must not be `/healthz` |
+| `MCP_STATELESS` | `false` | No sessions; for load-balanced setups |
+| `MCP_AUTH_TOKEN` | – | Bearer token for HTTP; required for non-loopback addresses; `/healthz` stays open |
+| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
+| `ENV_FILE` | `.env` | Alternative env file; `--env-file` overrides it |
+
+Flags override the transport-related environment values. Secrets are available
+only through the environment or the env file.
+
+## Dependencies and versioning
+
+- Direct dependencies:
+  - `github.com/modelcontextprotocol/go-sdk/mcp` for the protocol.
+  - `github.com/joho/godotenv` for correct `.env` parsing (quotes, comments,
+    `export`).
+- GraphQL is implemented with the standard library; do not add a GraphQL client
+  or code generator without a strong reason.
+- `version` is a `main` variable set with
+  `-ldflags "-X main.version=<value>"`; `make build` uses `git describe` and
+  falls back to `dev`.

+ 51 - 0
Makefile

@@ -0,0 +1,51 @@
+BINARY := bin/wikijs-mcp
+VERSION ?= $(shell git describe --tags --dirty 2>/dev/null || echo dev)
+LDFLAGS := -s -w -X main.version=$(VERSION)
+
+.PHONY: build test test-race vet fmt check run run-http docker-up docker-down clean help
+
+build:
+	@mkdir -p bin
+	go build -trimpath -ldflags '$(LDFLAGS)' -o $(BINARY) ./cmd/wikijs-mcp
+
+test:
+	go test ./...
+
+test-race:
+	go test -race ./...
+
+vet:
+	go vet ./...
+
+fmt:
+	gofmt -w .
+
+check: vet test-race
+
+run: build
+	./$(BINARY)
+
+run-http: build
+	./$(BINARY) -transport http -addr 127.0.0.1:8080 -path /mcp
+
+docker-up:
+	docker compose up -d
+
+docker-down:
+	docker compose down
+
+clean:
+	rm -rf bin
+
+help:
+	@echo "Targets:"
+	@echo "  build       build $(BINARY)"
+	@echo "  test        run unit tests"
+	@echo "  test-race   run unit tests with the race detector"
+	@echo "  vet         run go vet"
+	@echo "  check       vet + race tests"
+	@echo "  run         build and run with the stdio transport"
+	@echo "  run-http    build and run with the HTTP transport on 127.0.0.1:8080"
+	@echo "  docker-up   start a local WikiJS stack"
+	@echo "  docker-down stop the local WikiJS stack"
+	@echo "  clean       remove build artifacts"

+ 273 - 0
README.md

@@ -0,0 +1,273 @@
+# WikiJS MCP Server
+
+A [Model Context Protocol](https://modelcontextprotocol.io) server for
+[WikiJS](https://js.wiki). It lets MCP clients such as Claude Code, Claude
+Desktop, Cursor, or any custom agent search and read a WikiJS knowledge base,
+and optionally create or edit pages.
+
+This is a Go rewrite of
+[cahaseler/wikijs-mcp](https://github.com/cahaseler/wikijs-mcp). It has no Node.js
+runtime, no npm install step, and no GraphQL code generation; the result is a
+single static binary.
+
+## Features
+
+- `search_pages`, `get_page_by_id`, `get_page_by_path`, and `get_all_pages` for
+  reading a wiki.
+- `create_page` and `edit_page` when editing is explicitly enabled.
+- Absolute page URLs in every result.
+- **stdio** transport for local MCP clients.
+- **Streamable HTTP** transport with bearer-token authentication required for
+  non-loopback binds.
+- Structured MCP output (`structuredContent`) plus JSON text for older clients.
+- Configuration through environment variables or an optional `.env` file;
+  secrets never appear in flags.
+
+## Requirements
+
+- Go 1.25 or newer to build. The result is a single binary with no runtime
+  dependencies.
+- A WikiJS instance and an API key. In WikiJS, open **Administration → API
+  Access**, enable the API, create a key, and copy it. Read access is enough for
+  the four read tools; `create_page` and `edit_page` need write access.
+
+## Build
+
+From the repository root:
+
+```bash
+make build
+```
+
+The binary is written to `bin/wikijs-mcp`. You can also install it directly:
+
+```bash
+go install ./cmd/wikijs-mcp
+```
+
+Set the required environment variables:
+
+```bash
+export WIKIJS_URL=https://wiki.example.com
+export WIKIJS_API_KEY=your-api-key
+```
+
+`WIKIJS_URL` is the base URL of the wiki. A trailing slash or a `/graphql`
+suffix is accepted.
+
+### Using a `.env` file
+
+The server loads `.env` from the current working directory automatically when
+the file exists. Variables that are already exported take precedence, so a
+shell environment can always override the file:
+
+```bash
+cp env.example .env
+# edit .env
+./bin/wikijs-mcp
+```
+
+To use a different file, pass `--env-file` or set `ENV_FILE`:
+
+```bash
+./bin/wikijs-mcp --env-file /etc/wikijs-mcp.env
+# or:
+ENV_FILE=/etc/wikijs-mcp.env ./bin/wikijs-mcp
+```
+
+Precedence for selecting the file: `--env-file` > `ENV_FILE` > `./.env`.
+Values from the process environment always win over values from any env file.
+A missing default `.env` is ignored; an explicitly selected file must exist and
+be readable. The file supports comments, `export KEY=value`, and quoted values.
+
+## Use with the stdio transport
+
+The default transport is stdio. The MCP client starts the binary and
+communicates over stdin/stdout.
+
+### Claude Code
+
+```bash
+claude mcp add wikijs \
+  -e WIKIJS_URL=https://wiki.example.com \
+  -e WIKIJS_API_KEY=your-api-key \
+  -- /absolute/path/to/bin/wikijs-mcp
+```
+
+Add `-e WIKIJS_ENABLE_EDIT=true` to expose the page-editing tools.
+
+### Claude Desktop and Cursor
+
+Add an entry to the client's MCP configuration:
+
+```json
+{
+  "mcpServers": {
+    "wikijs": {
+      "command": "/absolute/path/to/bin/wikijs-mcp",
+      "env": {
+        "WIKIJS_URL": "https://wiki.example.com",
+        "WIKIJS_API_KEY": "your-api-key",
+        "WIKIJS_ENABLE_EDIT": "false"
+      }
+    }
+  }
+}
+```
+
+Typical configuration paths:
+
+- Claude Desktop (Linux): `~/.config/Claude/claude_desktop_config.json`
+- Claude Desktop (macOS): `~/Library/Application Support/Claude/claude_desktop_config.json`
+- Cursor: `~/.cursor/mcp.json`
+
+### Generic stdio client
+
+Run the binary with the environment above. It logs to stderr and speaks MCP
+JSON-RPC on stdout, so it must be launched by an MCP client rather than used
+interactively.
+
+## Use as an HTTP server
+
+Start the server with the Streamable HTTP transport:
+
+```bash
+export WIKIJS_URL=https://wiki.example.com
+export WIKIJS_API_KEY=your-api-key
+export MCP_AUTH_TOKEN="$(openssl rand -hex 32)"
+
+./bin/wikijs-mcp --transport http --addr :8080 --path /mcp
+```
+
+The MCP endpoint is `http://localhost:8080/mcp`. A liveness endpoint is
+available at `GET /healthz`; it is always reachable without a token.
+
+For local-only use, the default address is `127.0.0.1:8080`, so no token is
+needed:
+
+```bash
+./bin/wikijs-mcp --transport http
+```
+
+If `MCP_AUTH_TOKEN` is set, every MCP request must send:
+
+```
+Authorization: Bearer <token>
+```
+
+Without `MCP_AUTH_TOKEN`, the server refuses to start when `--addr`/`MCP_ADDR`
+is not a loopback address (`:8080`, `0.0.0.0:8080`, or a LAN hostname). This
+prevents accidentally exposing the wiki to the network.
+
+Connect an MCP client:
+
+```bash
+claude mcp add --transport http wikijs http://localhost:8080/mcp \
+  --header "Authorization: Bearer $MCP_AUTH_TOKEN"
+```
+
+You can also explore the server with the MCP Inspector:
+
+```bash
+npx @modelcontextprotocol/inspector --url http://localhost:8080/mcp \
+  --header "Authorization: Bearer $MCP_AUTH_TOKEN"
+```
+
+### Flags
+
+| Flag | Environment | Default | Description |
+|------|-------------|---------|-------------|
+| `--transport` | `MCP_TRANSPORT` | `stdio` | `stdio` or `http` |
+| `--addr` | `MCP_ADDR` | `127.0.0.1:8080` | Listen address for HTTP |
+| `--path` | `MCP_PATH` | `/mcp` | Path of the MCP endpoint |
+| `--stateless` | `MCP_STATELESS` | `false` | Disable MCP sessions (useful behind load balancers) |
+| `--log-level` | `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error` |
+| `--env-file` | `ENV_FILE` | `.env` | Env file to load (existing environment wins) |
+
+Notes for deployments:
+
+- By default the server uses stateful MCP sessions with a 30-minute idle
+  timeout. Use `--stateless` if requests may hit different replicas.
+- A reverse proxy in front of the server must support streaming responses and
+  must not buffer them (SSE).
+- The default listen address is loopback-only. To listen on all interfaces, set
+  `--addr :8080` and provide `MCP_AUTH_TOKEN`.
+- HTTP transport does not terminate TLS. Put it behind a TLS reverse proxy for
+  remote access.
+
+## Configuration
+
+| Variable | Required | Default | Description |
+|----------|----------|---------|-------------|
+| `WIKIJS_URL` | yes | – | Base URL of the WikiJS instance |
+| `WIKIJS_API_KEY` | yes | – | WikiJS API key, sent as a bearer token |
+| `WIKIJS_ENABLE_EDIT` | no | `false` | Expose `create_page` and `edit_page` |
+| `WIKIJS_TIMEOUT` | no | `30s` | Timeout for one GraphQL request |
+| `MCP_TRANSPORT` | no | `stdio` | `stdio` or `http` |
+| `MCP_ADDR` | no | `127.0.0.1:8080` | HTTP listen address |
+| `MCP_PATH` | no | `/mcp` | HTTP endpoint path |
+| `MCP_STATELESS` | no | `false` | Disable HTTP sessions |
+| `MCP_AUTH_TOKEN` | no | – | Bearer token for HTTP; required when `MCP_ADDR` is not loopback |
+| `LOG_LEVEL` | no | `info` | Log verbosity; logs always go to stderr |
+| `ENV_FILE` | no | `.env` | Alternative env file; `--env-file` overrides it |
+
+## Tools
+
+The four read tools are always available. Editing is disabled by default; set
+`WIKIJS_ENABLE_EDIT=true` to also expose the write tools.
+
+| Tool | Arguments | Description |
+|------|-----------|-------------|
+| `search_pages` | `query` (required), `path`, `locale` | Full-text search. Returns `results`, `suggestions`, and `totalHits`. |
+| `get_page_by_id` | `id` (required) | Full page by numeric ID, or `null`. |
+| `get_page_by_path` | `path`, `locale` (both required) | Full page by path and locale, or `null`. |
+| `get_all_pages` | `limit`, `locale`, `tags` | List pages with optional filters. |
+| `create_page` | `title`, `content`, `description`, `path` (required); `editor`, `isPublished`, `isPrivate`, `locale`, `tags` | Create a page. |
+| `edit_page` | `id`, `title`, `content`, `description`, `path` (required); `editor`, `isPublished`, `isPrivate`, `locale`, `tags` | Update a page. |
+
+Defaults for the write tools: `editor=markdown`, `isPublished=true`,
+`isPrivate=false`, `locale=en`, `tags=[]`.
+
+Locales use the form `en` or `de-DE` (two lowercase letters, optionally followed
+by a hyphen and two uppercase letters). Every page result includes a `url` built
+from `WIKIJS_URL`; the locale is part of the URL for non-English pages.
+
+## Local WikiJS for development
+
+```bash
+docker compose up -d
+```
+
+WikiJS is then available at <http://localhost:3000>. Complete the setup wizard,
+create an API key under **Administration → API Access**, set `WIKIJS_URL` and
+`WIKIJS_API_KEY`, and run the server.
+
+## Development
+
+```bash
+make test        # unit tests
+make test-race   # unit tests with the race detector
+make vet         # go vet
+make check       # vet + race tests
+make run-http    # run the HTTP transport on 127.0.0.1:8080
+```
+
+Tests cover the GraphQL client against an `httptest` server, all tool handlers
+through an in-memory MCP session, and the HTTP transport including bearer-token
+authentication. See [AGENTS.md](AGENTS.md) for the code layout and conventions.
+
+## Security
+
+- Keep `WIKIJS_API_KEY` and `MCP_AUTH_TOKEN` out of version control. Prefer the
+  environment or a secrets manager over committing `.env`; keep the file mode
+  restrictive (`chmod 600 .env`).
+- Editing is disabled by default. Enable it only for clients and environments
+  you trust.
+- The HTTP transport refuses non-loopback addresses unless `MCP_AUTH_TOKEN` is
+  set. Even with a token, use TLS (for example via a reverse proxy) before
+  exposing it to untrusted networks.
+- The server never logs API keys, bearer tokens, or request bodies.
+
+## License
+
+GPL-3.0-or-later. See [LICENSE](LICENSE).

+ 184 - 0
cmd/wikijs-mcp/main.go

@@ -0,0 +1,184 @@
+// Command wikijs-mcp exposes a WikiJS instance as a Model Context Protocol
+// server over stdio or streamable HTTP.
+package main
+
+import (
+	"context"
+	"errors"
+	"flag"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"os"
+	"os/signal"
+	"strings"
+	"syscall"
+
+	"wikijs-mcp/internal/config"
+	"wikijs-mcp/internal/server"
+	"wikijs-mcp/internal/transport"
+	"wikijs-mcp/internal/wikijs"
+
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// version is set at build time with -ldflags "-X main.version=...".
+var version = "dev"
+
+func main() {
+	if err := run(os.Args[1:]); err != nil {
+		fmt.Fprintf(os.Stderr, "wikijs-mcp: %v\n", err)
+		os.Exit(1)
+	}
+}
+
+func run(args []string) error {
+	envFile, useEnvFile, err := envFileFromArgs(args)
+	if err != nil {
+		return err
+	}
+	if useEnvFile {
+		if err := config.LoadEnvFile(envFile); err != nil {
+			return err
+		}
+	} else if err := config.LoadDotEnv(); err != nil {
+		return err
+	}
+
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+
+	fs := flag.NewFlagSet("wikijs-mcp", flag.ContinueOnError)
+	fs.SetOutput(os.Stderr)
+	fs.Usage = func() {
+		out := fs.Output()
+		fmt.Fprintln(out, "Usage: wikijs-mcp [flags]")
+		fmt.Fprintln(out)
+		fmt.Fprintln(out, "WikiJS connection (environment):")
+		fmt.Fprintln(out, "  WIKIJS_URL          Base URL of the WikiJS instance (required)")
+		fmt.Fprintln(out, "  WIKIJS_API_KEY      WikiJS API key (required)")
+		fmt.Fprintln(out, "  WIKIJS_ENABLE_EDIT  Expose create_page and edit_page (default false)")
+		fmt.Fprintln(out, "  WIKIJS_TIMEOUT      GraphQL request timeout (default 30s)")
+		fmt.Fprintln(out)
+		fmt.Fprintln(out, "Other environment:")
+		fmt.Fprintln(out, "  ENV_FILE            env file to load (default \".env\")")
+		fmt.Fprintln(out, "  MCP_AUTH_TOKEN      bearer token for HTTP (required for non-loopback)")
+		fmt.Fprintln(out)
+		fmt.Fprintln(out, "Flags:")
+		fs.PrintDefaults()
+	}
+	fs.String("env-file", "", "env file to load (overrides ENV_FILE and .env)")
+	fs.StringVar(&cfg.Transport, "transport", cfg.Transport, `"stdio" or "http"`)
+	fs.StringVar(&cfg.Addr, "addr", cfg.Addr, "listen address for the HTTP transport")
+	fs.StringVar(&cfg.Path, "path", cfg.Path, "MCP endpoint path for the HTTP transport")
+	fs.BoolVar(&cfg.Stateless, "stateless", cfg.Stateless, "disable MCP sessions on the HTTP transport")
+	logLevel := strings.ToLower(cfg.LogLevel.String())
+	fs.StringVar(&logLevel, "log-level", logLevel, "debug, info, warn, or error")
+	showVersion := fs.Bool("version", false, "print the version and exit")
+
+	if err := fs.Parse(args); err != nil {
+		if errors.Is(err, flag.ErrHelp) {
+			return nil
+		}
+		return err
+	}
+	if *showVersion {
+		fmt.Println(version)
+		return nil
+	}
+	if fs.NArg() > 0 {
+		return fmt.Errorf("unexpected arguments: %v", fs.Args())
+	}
+	if cfg.LogLevel, err = config.ParseLogLevel(logLevel); err != nil {
+		return err
+	}
+	if err := cfg.Validate(); err != nil {
+		return err
+	}
+
+	logger := newLogger(cfg.LogLevel)
+	protocolLogger := newLogger(slog.LevelWarn)
+	if cfg.LogLevel == slog.LevelDebug {
+		protocolLogger = logger
+	}
+
+	wikiClient, err := wikijs.New(cfg.WikiJSURL, cfg.WikiJSAPIKey,
+		wikijs.WithHTTPClient(&http.Client{Timeout: cfg.Timeout}),
+		wikijs.WithLogger(logger),
+		wikijs.WithUserAgent("wikijs-mcp/"+version),
+	)
+	if err != nil {
+		return err
+	}
+
+	mcpServer := server.New(wikiClient, server.Options{
+		Version:    version,
+		EnableEdit: cfg.EnableEdit,
+		Logger:     protocolLogger,
+	})
+
+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+	defer stop()
+
+	switch cfg.Transport {
+	case config.TransportStdio:
+		logger.Info("WikiJS MCP server running", "transport", "stdio", "version", version, "edit", cfg.EnableEdit)
+		if err := mcpServer.Run(ctx, &mcp.StdioTransport{}); err != nil && !errors.Is(err, context.Canceled) {
+			return fmt.Errorf("stdio server: %w", err)
+		}
+		return nil
+
+	case config.TransportHTTP:
+		return transport.ServeHTTP(ctx, transport.HTTPOptions{
+			Addr:           cfg.Addr,
+			Path:           cfg.Path,
+			Stateless:      cfg.Stateless,
+			AuthToken:      cfg.AuthToken,
+			Logger:         logger,
+			ProtocolLogger: protocolLogger,
+			NewServer:      func(*http.Request) *mcp.Server { return mcpServer },
+		})
+
+	default:
+		return fmt.Errorf("unsupported transport %q", cfg.Transport)
+	}
+}
+
+func newLogger(level slog.Level) *slog.Logger {
+	return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
+		Level: level,
+	}))
+}
+
+// envFileFromArgs finds --env-file before flag parsing, because the env file
+// must be loaded before the configuration is read. The regular flag set also
+// defines --env-file so it appears in help and is parsed consistently.
+func envFileFromArgs(args []string) (path string, found bool, err error) {
+	for i := 0; i < len(args); i++ {
+		arg := args[i]
+		if arg == "--" {
+			break
+		}
+		switch {
+		case arg == "--env-file" || arg == "-env-file":
+			if i+1 >= len(args) {
+				return "", true, fmt.Errorf("flag needs an argument: %s", arg)
+			}
+			path = args[i+1]
+			found = true
+			i++
+		case strings.HasPrefix(arg, "--env-file="):
+			path = strings.TrimPrefix(arg, "--env-file=")
+			found = true
+		case strings.HasPrefix(arg, "-env-file="):
+			path = strings.TrimPrefix(arg, "-env-file=")
+			found = true
+		}
+	}
+	if found && strings.TrimSpace(path) == "" {
+		return "", true, errors.New("--env-file must not be empty")
+	}
+	return path, found, nil
+}

+ 112 - 0
cmd/wikijs-mcp/main_test.go

@@ -0,0 +1,112 @@
+package main
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func clearEnv(t *testing.T) {
+	t.Helper()
+	t.Chdir(t.TempDir())
+	for _, key := range []string{
+		"WIKIJS_URL", "WIKIJS_API_KEY", "WIKIJS_ENABLE_EDIT", "WIKIJS_TIMEOUT",
+		"MCP_TRANSPORT", "MCP_ADDR", "MCP_PATH", "MCP_STATELESS",
+		"MCP_AUTH_TOKEN", "LOG_LEVEL", "ENV_FILE",
+	} {
+		t.Setenv(key, "")
+	}
+}
+
+func TestVersionFlagNeedsNoCredentials(t *testing.T) {
+	clearEnv(t)
+	if err := run([]string{"--version"}); err != nil {
+		t.Fatalf("run(--version) error = %v", err)
+	}
+}
+
+func TestHelpNeedsNoCredentials(t *testing.T) {
+	clearEnv(t)
+	// Silence the usage text; flag.Parse writes it to the flag set output.
+	stdout, stderr := os.Stdout, os.Stderr
+	devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
+	if err != nil {
+		t.Fatalf("open %s: %v", os.DevNull, err)
+	}
+	defer devNull.Close()
+	os.Stdout, os.Stderr = devNull, devNull
+	defer func() {
+		os.Stdout, os.Stderr = stdout, stderr
+	}()
+
+	if err := run([]string{"--help"}); err != nil {
+		t.Fatalf("run(--help) error = %v", err)
+	}
+}
+
+func TestRunRequiresCredentials(t *testing.T) {
+	clearEnv(t)
+	if err := run(nil); err == nil {
+		t.Fatal("run() error = nil, want missing credentials error")
+	}
+}
+
+func TestFlagsOverrideTransport(t *testing.T) {
+	clearEnv(t)
+	t.Setenv("WIKIJS_URL", "https://wiki.example.com")
+	t.Setenv("WIKIJS_API_KEY", "secret")
+	t.Setenv("MCP_TRANSPORT", "http")
+
+	if err := run([]string{"--transport", "sse"}); err == nil {
+		t.Fatal("run() error = nil, want invalid transport error")
+	}
+}
+
+func TestEnvFileFromArgs(t *testing.T) {
+	tests := []struct {
+		name      string
+		args      []string
+		wantPath  string
+		wantFound bool
+		wantErr   bool
+	}{
+		{name: "none"},
+		{name: "separate", args: []string{"--env-file", "custom.env"}, wantPath: "custom.env", wantFound: true},
+		{name: "equals", args: []string{"--env-file=custom.env"}, wantPath: "custom.env", wantFound: true},
+		{name: "single dash", args: []string{"-env-file", "custom.env"}, wantPath: "custom.env", wantFound: true},
+		{name: "single dash equals", args: []string{"-env-file=custom.env"}, wantPath: "custom.env", wantFound: true},
+		{name: "last wins", args: []string{"--env-file=a.env", "--env-file", "b.env"}, wantPath: "b.env", wantFound: true},
+		{name: "missing value", args: []string{"--env-file"}, wantFound: true, wantErr: true},
+		{name: "empty value", args: []string{"--env-file="}, wantFound: true, wantErr: true},
+		{name: "after terminator", args: []string{"--", "--env-file", "custom.env"}},
+		{name: "other flags", args: []string{"--transport", "http"}},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			path, found, err := envFileFromArgs(tt.args)
+			if gotErr := err != nil; gotErr != tt.wantErr {
+				t.Fatalf("envFileFromArgs() error = %v, wantErr %v", err, tt.wantErr)
+			}
+			if path != tt.wantPath {
+				t.Errorf("path = %q, want %q", path, tt.wantPath)
+			}
+			if found != tt.wantFound {
+				t.Errorf("found = %v, want %v", found, tt.wantFound)
+			}
+		})
+	}
+}
+
+func TestEnvFileFlagOverridesEnvFile(t *testing.T) {
+	clearEnv(t)
+	t.Setenv("ENV_FILE", filepath.Join(t.TempDir(), "does-not-exist.env"))
+
+	good := filepath.Join(t.TempDir(), "good.env")
+	if err := os.WriteFile(good, []byte("# empty\n"), 0o600); err != nil {
+		t.Fatalf("write env file: %v", err)
+	}
+
+	if err := run([]string{"--env-file", good, "--version"}); err != nil {
+		t.Fatalf("run(--env-file ... --version) error = %v", err)
+	}
+}

+ 43 - 0
docker-compose.yml

@@ -0,0 +1,43 @@
+services:
+  db:
+    image: postgres:15-alpine
+    environment:
+      POSTGRES_DB: wiki
+      POSTGRES_PASSWORD: wikijsrocks
+      POSTGRES_USER: wikijs
+    logging:
+      driver: "none"
+    restart: unless-stopped
+    volumes:
+      - db-data:/var/lib/postgresql/data
+    networks:
+      - wikijs
+
+  wiki:
+    image: ghcr.io/requarks/wiki:2
+    depends_on:
+      - db
+    environment:
+      DB_TYPE: postgres
+      DB_HOST: db
+      DB_PORT: 5432
+      DB_USER: wikijs
+      DB_PASS: wikijsrocks
+      DB_NAME: wiki
+    logging:
+      driver: "json-file"
+      options:
+        max-size: "10m"
+        max-file: "3"
+    restart: unless-stopped
+    ports:
+      - "3000:3000"
+    networks:
+      - wikijs
+
+volumes:
+  db-data:
+
+networks:
+  wikijs:
+    driver: bridge

+ 19 - 0
env.example

@@ -0,0 +1,19 @@
+# Required
+WIKIJS_URL=http://localhost:3000
+WIKIJS_API_KEY=
+WIKIJS_ENABLE_EDIT=false
+
+# Optional
+# WIKIJS_TIMEOUT=30s
+# LOG_LEVEL=info
+
+# Optional HTTP transport
+# MCP_TRANSPORT=http
+# MCP_ADDR=127.0.0.1:8080
+# MCP_PATH=/mcp
+# MCP_STATELESS=false
+# Required when MCP_ADDR is not loopback:
+# MCP_AUTH_TOKEN=
+
+# Load a different env file instead of ./.env
+# ENV_FILE=/etc/wikijs-mcp.env

+ 19 - 0
go.mod

@@ -0,0 +1,19 @@
+module wikijs-mcp
+
+go 1.25.0
+
+require (
+	github.com/joho/godotenv v1.5.1
+	github.com/modelcontextprotocol/go-sdk v1.8.0
+)
+
+require (
+	github.com/google/jsonschema-go v0.4.3 // indirect
+	github.com/segmentio/asm v1.1.3 // indirect
+	github.com/segmentio/encoding v0.5.4 // indirect
+	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+	golang.org/x/oauth2 v0.35.0 // indirect
+	golang.org/x/sync v0.20.0 // indirect
+	golang.org/x/sys v0.41.0 // indirect
+	golang.org/x/time v0.15.0 // indirect
+)

+ 26 - 0
go.sum

@@ -0,0 +1,26 @@
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
+github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
+github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
+github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
+github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
+github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
+github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
+golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
+golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
+golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=

+ 194 - 0
internal/config/config.go

@@ -0,0 +1,194 @@
+// Package config loads and validates the server configuration from the
+// environment.
+package config
+
+import (
+	"errors"
+	"fmt"
+	"log/slog"
+	"net"
+	"net/url"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// Supported MCP transports.
+const (
+	TransportStdio = "stdio"
+	TransportHTTP  = "http"
+)
+
+// Defaults for optional settings.
+const (
+	DefaultAddr     = "127.0.0.1:8080"
+	DefaultPath     = "/mcp"
+	DefaultTimeout  = 30 * time.Second
+	DefaultLogLevel = slog.LevelInfo
+)
+
+// Config holds the complete runtime configuration.
+type Config struct {
+	// WikiJS connection.
+	WikiJSURL    string
+	WikiJSAPIKey string
+
+	// EnableEdit exposes the create_page and edit_page tools.
+	EnableEdit bool
+
+	// Timeout applies to a single WikiJS GraphQL request.
+	Timeout time.Duration
+
+	// Transport is either TransportStdio or TransportHTTP.
+	Transport string
+
+	// Addr is the listen address for the HTTP transport.
+	Addr string
+
+	// Path is the HTTP path that serves the MCP endpoint.
+	Path string
+
+	// Stateless disables MCP sessions on the HTTP transport.
+	Stateless bool
+
+	// AuthToken protects the HTTP transport with a bearer token. Empty means
+	// no authentication.
+	AuthToken string
+
+	// LogLevel controls the stderr logger.
+	LogLevel slog.Level
+}
+
+// Load reads the configuration from the environment and applies defaults.
+// Required values and transport settings are validated by [Config.Validate] so
+// that flags such as -version and -help work without credentials present.
+func Load() (Config, error) {
+	cfg := Config{
+		WikiJSURL:    strings.TrimSpace(os.Getenv("WIKIJS_URL")),
+		WikiJSAPIKey: strings.TrimSpace(os.Getenv("WIKIJS_API_KEY")),
+		Transport:    strings.ToLower(strings.TrimSpace(os.Getenv("MCP_TRANSPORT"))),
+		Addr:         strings.TrimSpace(os.Getenv("MCP_ADDR")),
+		Path:         strings.TrimSpace(os.Getenv("MCP_PATH")),
+		AuthToken:    strings.TrimSpace(os.Getenv("MCP_AUTH_TOKEN")),
+		Timeout:      DefaultTimeout,
+		LogLevel:     DefaultLogLevel,
+	}
+
+	if cfg.Transport == "" {
+		cfg.Transport = TransportStdio
+	}
+	if cfg.Addr == "" {
+		cfg.Addr = DefaultAddr
+	}
+	if cfg.Path == "" {
+		cfg.Path = DefaultPath
+	}
+
+	var err error
+	if cfg.EnableEdit, err = boolEnv("WIKIJS_ENABLE_EDIT", false); err != nil {
+		return Config{}, err
+	}
+	if cfg.Stateless, err = boolEnv("MCP_STATELESS", false); err != nil {
+		return Config{}, err
+	}
+	if raw := strings.TrimSpace(os.Getenv("WIKIJS_TIMEOUT")); raw != "" {
+		cfg.Timeout, err = time.ParseDuration(raw)
+		if err != nil {
+			return Config{}, fmt.Errorf("WIKIJS_TIMEOUT: %w", err)
+		}
+	}
+	if raw := strings.TrimSpace(os.Getenv("LOG_LEVEL")); raw != "" {
+		if cfg.LogLevel, err = ParseLogLevel(raw); err != nil {
+			return Config{}, err
+		}
+	}
+
+	return cfg, nil
+}
+
+// Validate checks required values and the settings that can be overridden by
+// command line flags.
+func (c *Config) Validate() error {
+	if c.WikiJSURL == "" {
+		return errors.New("WIKIJS_URL is required")
+	}
+	if c.WikiJSAPIKey == "" {
+		return errors.New("WIKIJS_API_KEY is required")
+	}
+
+	u, err := url.Parse(c.WikiJSURL)
+	if err != nil {
+		return fmt.Errorf("WIKIJS_URL: %w", err)
+	}
+	if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
+		return fmt.Errorf("WIKIJS_URL must be an absolute http(s) URL, got %q", c.WikiJSURL)
+	}
+	if u.RawQuery != "" || u.Fragment != "" {
+		return fmt.Errorf("WIKIJS_URL must not contain a query or fragment, got %q", c.WikiJSURL)
+	}
+
+	switch c.Transport {
+	case TransportStdio, TransportHTTP:
+	default:
+		return fmt.Errorf("MCP_TRANSPORT must be %q or %q, got %q", TransportStdio, TransportHTTP, c.Transport)
+	}
+	if c.Addr == "" {
+		return errors.New("MCP_ADDR must not be empty")
+	}
+	if c.Transport == TransportHTTP {
+		host, port, err := net.SplitHostPort(c.Addr)
+		if err != nil || port == "" {
+			return fmt.Errorf("MCP_ADDR must be a host:port address, got %q", c.Addr)
+		}
+		if c.AuthToken == "" && !isLoopbackHost(host) {
+			return fmt.Errorf("MCP_ADDR %q is not loopback; set MCP_AUTH_TOKEN or bind to 127.0.0.1", c.Addr)
+		}
+	}
+	if !strings.HasPrefix(c.Path, "/") {
+		return fmt.Errorf("MCP_PATH must start with %q, got %q", "/", c.Path)
+	}
+	if c.Path == "/healthz" {
+		return errors.New(`MCP_PATH must not be "/healthz"`)
+	}
+	if c.Timeout <= 0 {
+		return errors.New("WIKIJS_TIMEOUT must be positive")
+	}
+	return nil
+}
+
+func isLoopbackHost(host string) bool {
+	if strings.EqualFold(host, "localhost") {
+		return true
+	}
+	ip := net.ParseIP(host)
+	return ip != nil && ip.IsLoopback()
+}
+
+func boolEnv(name string, fallback bool) (bool, error) {
+	raw := strings.TrimSpace(os.Getenv(name))
+	if raw == "" {
+		return fallback, nil
+	}
+	v, err := strconv.ParseBool(raw)
+	if err != nil {
+		return false, fmt.Errorf("%s must be a boolean: %w", name, err)
+	}
+	return v, nil
+}
+
+// ParseLogLevel turns a human-readable level name into a [slog.Level].
+func ParseLogLevel(raw string) (slog.Level, error) {
+	switch strings.ToLower(raw) {
+	case "debug":
+		return slog.LevelDebug, nil
+	case "info":
+		return slog.LevelInfo, nil
+	case "warn", "warning":
+		return slog.LevelWarn, nil
+	case "error":
+		return slog.LevelError, nil
+	default:
+		return 0, fmt.Errorf("LOG_LEVEL must be one of debug, info, warn, error; got %q", raw)
+	}
+}

+ 216 - 0
internal/config/config_test.go

@@ -0,0 +1,216 @@
+package config
+
+import (
+	"log/slog"
+	"testing"
+	"time"
+)
+
+func setRequired(t *testing.T) {
+	t.Helper()
+	t.Setenv("WIKIJS_URL", "https://wiki.example.com")
+	t.Setenv("WIKIJS_API_KEY", "secret")
+}
+
+func TestLoadDefaults(t *testing.T) {
+	setRequired(t)
+	t.Setenv("WIKIJS_ENABLE_EDIT", "")
+	t.Setenv("WIKIJS_TIMEOUT", "")
+	t.Setenv("MCP_TRANSPORT", "")
+	t.Setenv("MCP_ADDR", "")
+	t.Setenv("MCP_PATH", "")
+	t.Setenv("MCP_STATELESS", "")
+	t.Setenv("MCP_AUTH_TOKEN", "")
+	t.Setenv("LOG_LEVEL", "")
+
+	cfg, err := Load()
+	if err != nil {
+		t.Fatalf("Load() error = %v", err)
+	}
+	if cfg.Transport != TransportStdio {
+		t.Errorf("Transport = %q, want %q", cfg.Transport, TransportStdio)
+	}
+	if cfg.Addr != DefaultAddr {
+		t.Errorf("Addr = %q, want %q", cfg.Addr, DefaultAddr)
+	}
+	if cfg.Path != DefaultPath {
+		t.Errorf("Path = %q, want %q", cfg.Path, DefaultPath)
+	}
+	if cfg.Timeout != DefaultTimeout {
+		t.Errorf("Timeout = %v, want %v", cfg.Timeout, DefaultTimeout)
+	}
+	if cfg.EnableEdit {
+		t.Error("EnableEdit = true, want false")
+	}
+	if cfg.Stateless {
+		t.Error("Stateless = true, want false")
+	}
+	if cfg.LogLevel != slog.LevelInfo {
+		t.Errorf("LogLevel = %v, want info", cfg.LogLevel)
+	}
+	if err := cfg.Validate(); err != nil {
+		t.Errorf("Validate() error = %v", err)
+	}
+}
+
+func TestLoadCustomValues(t *testing.T) {
+	setRequired(t)
+	t.Setenv("WIKIJS_ENABLE_EDIT", "true")
+	t.Setenv("WIKIJS_TIMEOUT", "5s")
+	t.Setenv("MCP_TRANSPORT", "HTTP")
+	t.Setenv("MCP_ADDR", "127.0.0.1:9000")
+	t.Setenv("MCP_PATH", "/mcp/")
+	t.Setenv("MCP_STATELESS", "1")
+	t.Setenv("MCP_AUTH_TOKEN", "token")
+	t.Setenv("LOG_LEVEL", "debug")
+
+	cfg, err := Load()
+	if err != nil {
+		t.Fatalf("Load() error = %v", err)
+	}
+	if cfg.Transport != TransportHTTP {
+		t.Errorf("Transport = %q, want %q", cfg.Transport, TransportHTTP)
+	}
+	if cfg.Addr != "127.0.0.1:9000" {
+		t.Errorf("Addr = %q", cfg.Addr)
+	}
+	if cfg.Path != "/mcp/" {
+		t.Errorf("Path = %q", cfg.Path)
+	}
+	if cfg.Timeout != 5*time.Second {
+		t.Errorf("Timeout = %v", cfg.Timeout)
+	}
+	if !cfg.EnableEdit || !cfg.Stateless {
+		t.Errorf("EnableEdit = %v, Stateless = %v, want both true", cfg.EnableEdit, cfg.Stateless)
+	}
+	if cfg.AuthToken != "token" {
+		t.Errorf("AuthToken = %q", cfg.AuthToken)
+	}
+	if cfg.LogLevel != slog.LevelDebug {
+		t.Errorf("LogLevel = %v, want debug", cfg.LogLevel)
+	}
+}
+
+func TestLoadRequired(t *testing.T) {
+	tests := []struct {
+		name    string
+		url     string
+		apiKey  string
+		wantErr bool
+	}{
+		{name: "missing url", apiKey: "secret", wantErr: true},
+		{name: "missing key", url: "https://wiki.example.com", wantErr: true},
+		{name: "present", url: "https://wiki.example.com", apiKey: "secret"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Setenv("WIKIJS_URL", tt.url)
+			t.Setenv("WIKIJS_API_KEY", tt.apiKey)
+			cfg, err := Load()
+			if err != nil {
+				t.Fatalf("Load() error = %v", err)
+			}
+			if gotErr := cfg.Validate() != nil; gotErr != tt.wantErr {
+				t.Fatalf("Validate() error = %v, wantErr %v", cfg.Validate(), tt.wantErr)
+			}
+		})
+	}
+}
+
+func TestLoadInvalidValues(t *testing.T) {
+	tests := []struct {
+		name  string
+		key   string
+		value string
+	}{
+		{name: "edit bool", key: "WIKIJS_ENABLE_EDIT", value: "maybe"},
+		{name: "stateless bool", key: "MCP_STATELESS", value: "maybe"},
+		{name: "timeout", key: "WIKIJS_TIMEOUT", value: "soon"},
+		{name: "log level", key: "LOG_LEVEL", value: "verbose"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			setRequired(t)
+			t.Setenv(tt.key, tt.value)
+			if _, err := Load(); err == nil {
+				t.Fatal("Load() error = nil, want error")
+			}
+		})
+	}
+}
+
+func TestValidateHTTPAuth(t *testing.T) {
+	tests := []struct {
+		name    string
+		addr    string
+		token   string
+		wantErr bool
+	}{
+		{name: "all interfaces without token", addr: ":8080", wantErr: true},
+		{name: "all interfaces with token", addr: ":8080", token: "secret"},
+		{name: "ipv4 any without token", addr: "0.0.0.0:8080", wantErr: true},
+		{name: "ipv4 loopback", addr: "127.0.0.1:8080"},
+		{name: "ipv6 loopback", addr: "[::1]:8080"},
+		{name: "localhost", addr: "localhost:8080"},
+		{name: "remote hostname without token", addr: "wiki.internal:8080", wantErr: true},
+		{name: "missing port", addr: "127.0.0.1", wantErr: true},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			cfg := Config{
+				WikiJSURL:    "https://wiki.example.com",
+				WikiJSAPIKey: "secret",
+				Transport:    TransportHTTP,
+				Addr:         tt.addr,
+				Path:         DefaultPath,
+				Timeout:      DefaultTimeout,
+				AuthToken:    tt.token,
+			}
+			if gotErr := cfg.Validate() != nil; gotErr != tt.wantErr {
+				t.Fatalf("Validate() error = %v, wantErr %v", cfg.Validate(), tt.wantErr)
+			}
+		})
+	}
+}
+
+func TestValidate(t *testing.T) {
+	valid := func() Config {
+		return Config{
+			WikiJSURL:    "https://wiki.example.com",
+			WikiJSAPIKey: "secret",
+			Transport:    TransportStdio,
+			Addr:         DefaultAddr,
+			Path:         DefaultPath,
+			Timeout:      DefaultTimeout,
+		}
+	}
+
+	tests := []struct {
+		name    string
+		mutate  func(*Config)
+		wantErr bool
+	}{
+		{name: "valid"},
+		{name: "missing url", mutate: func(c *Config) { c.WikiJSURL = "" }, wantErr: true},
+		{name: "missing key", mutate: func(c *Config) { c.WikiJSAPIKey = "" }, wantErr: true},
+		{name: "relative url", mutate: func(c *Config) { c.WikiJSURL = "wiki.example.com" }, wantErr: true},
+		{name: "url with query", mutate: func(c *Config) { c.WikiJSURL = "https://wiki.example.com/?a=1" }, wantErr: true},
+		{name: "ftp url", mutate: func(c *Config) { c.WikiJSURL = "ftp://wiki.example.com" }, wantErr: true},
+		{name: "bad transport", mutate: func(c *Config) { c.Transport = "sse" }, wantErr: true},
+		{name: "empty addr", mutate: func(c *Config) { c.Addr = "" }, wantErr: true},
+		{name: "path without slash", mutate: func(c *Config) { c.Path = "mcp" }, wantErr: true},
+		{name: "healthz path", mutate: func(c *Config) { c.Path = "/healthz" }, wantErr: true},
+		{name: "zero timeout", mutate: func(c *Config) { c.Timeout = 0 }, wantErr: true},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			cfg := valid()
+			if tt.mutate != nil {
+				tt.mutate(&cfg)
+			}
+			if gotErr := cfg.Validate() != nil; gotErr != tt.wantErr {
+				t.Fatalf("Validate() error = %v, wantErr %v", cfg.Validate(), tt.wantErr)
+			}
+		})
+	}
+}

+ 47 - 0
internal/config/dotenv.go

@@ -0,0 +1,47 @@
+package config
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/joho/godotenv"
+)
+
+// DefaultEnvFile is loaded automatically when it exists.
+const DefaultEnvFile = ".env"
+
+// LoadDotEnv loads KEY=VALUE pairs from the file named by ENV_FILE, or from
+// .env in the current directory when ENV_FILE is unset.
+//
+// Variables that already exist in the process environment are never
+// overwritten, so exported shell variables take precedence over the file.
+// A missing default .env is ignored; an explicitly configured ENV_FILE must
+// exist and be readable.
+func LoadDotEnv() error {
+	if path := strings.TrimSpace(os.Getenv("ENV_FILE")); path != "" {
+		return LoadEnvFile(path)
+	}
+
+	if err := godotenv.Load(DefaultEnvFile); err != nil {
+		if errors.Is(err, os.ErrNotExist) {
+			return nil
+		}
+		return fmt.Errorf("load env file %s: %w", DefaultEnvFile, err)
+	}
+	return nil
+}
+
+// LoadEnvFile loads KEY=VALUE pairs from an explicit file. Existing process
+// variables are not overwritten. The file must exist and be readable.
+func LoadEnvFile(path string) error {
+	path = strings.TrimSpace(path)
+	if path == "" {
+		return errors.New("env file path must not be empty")
+	}
+	if err := godotenv.Load(path); err != nil {
+		return fmt.Errorf("load env file %s: %w", path, err)
+	}
+	return nil
+}

+ 90 - 0
internal/config/dotenv_test.go

@@ -0,0 +1,90 @@
+package config
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func unsetEnv(t *testing.T, key string) {
+	t.Helper()
+	os.Unsetenv(key)
+	t.Cleanup(func() { os.Unsetenv(key) })
+}
+
+func TestLoadDotEnvLoadsValues(t *testing.T) {
+	const key = "WIKIJS_TEST_DOTENV_LOAD"
+	unsetEnv(t, key)
+
+	path := filepath.Join(t.TempDir(), ".env")
+	body := "# comment\n\nWIKIJS_TEST_DOTENV_LOAD=\"hello world\"\n"
+	if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
+		t.Fatalf("write env file: %v", err)
+	}
+	t.Setenv("ENV_FILE", path)
+
+	if err := LoadDotEnv(); err != nil {
+		t.Fatalf("LoadDotEnv() error = %v", err)
+	}
+	if got, want := os.Getenv(key), "hello world"; got != want {
+		t.Errorf("%s = %q, want %q", key, got, want)
+	}
+}
+
+func TestLoadEnvFileExplicit(t *testing.T) {
+	const key = "WIKIJS_TEST_ENVFILE_EXPLICIT"
+	unsetEnv(t, key)
+
+	path := filepath.Join(t.TempDir(), "custom.env")
+	if err := os.WriteFile(path, []byte(key+"=value\n"), 0o600); err != nil {
+		t.Fatalf("write env file: %v", err)
+	}
+
+	if err := LoadEnvFile(path); err != nil {
+		t.Fatalf("LoadEnvFile() error = %v", err)
+	}
+	if got, want := os.Getenv(key), "value"; got != want {
+		t.Errorf("%s = %q, want %q", key, got, want)
+	}
+}
+
+func TestLoadEnvFileEmptyPath(t *testing.T) {
+	if err := LoadEnvFile("  "); err == nil {
+		t.Fatal("LoadEnvFile() error = nil, want error")
+	}
+}
+
+func TestLoadDotEnvDoesNotOverrideProcessEnv(t *testing.T) {
+	const key = "WIKIJS_TEST_DOTENV_OVERRIDE"
+
+	path := filepath.Join(t.TempDir(), ".env")
+	if err := os.WriteFile(path, []byte(key+"=from-file\n"), 0o600); err != nil {
+		t.Fatalf("write env file: %v", err)
+	}
+	t.Setenv("ENV_FILE", path)
+	t.Setenv(key, "from-process")
+
+	if err := LoadDotEnv(); err != nil {
+		t.Fatalf("LoadDotEnv() error = %v", err)
+	}
+	if got, want := os.Getenv(key), "from-process"; got != want {
+		t.Errorf("%s = %q, want %q", key, got, want)
+	}
+}
+
+func TestLoadDotEnvMissingDefaultIsIgnored(t *testing.T) {
+	t.Chdir(t.TempDir())
+	t.Setenv("ENV_FILE", "")
+
+	if err := LoadDotEnv(); err != nil {
+		t.Fatalf("LoadDotEnv() error = %v", err)
+	}
+}
+
+func TestLoadDotEnvMissingExplicitFileFails(t *testing.T) {
+	t.Setenv("ENV_FILE", filepath.Join(t.TempDir(), "does-not-exist.env"))
+
+	if err := LoadDotEnv(); err == nil {
+		t.Fatal("LoadDotEnv() error = nil, want error")
+	}
+}

+ 90 - 0
internal/server/server.go

@@ -0,0 +1,90 @@
+// Package server builds the MCP server and registers the WikiJS tools.
+package server
+
+import (
+	"log/slog"
+
+	"wikijs-mcp/internal/wikijs"
+
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+const instructions = `This server exposes a WikiJS knowledge base.
+Use search_pages to find pages by full-text query, get_page_by_id or
+get_page_by_path to read a page, and get_all_pages to browse the wiki.
+Page results include absolute URLs that can be shared with the user.`
+
+// Options configures the MCP server.
+type Options struct {
+	// Version is reported to MCP clients.
+	Version string
+	// EnableEdit exposes the create_page and edit_page tools.
+	EnableEdit bool
+	// Logger receives MCP protocol logs. It must write to stderr when the
+	// stdio transport is used.
+	Logger *slog.Logger
+}
+
+// New creates an MCP server with all WikiJS tools registered.
+func New(client *wikijs.Client, opts Options) *mcp.Server {
+	logger := opts.Logger
+	if logger == nil {
+		logger = slog.New(slog.DiscardHandler)
+	}
+
+	s := mcp.NewServer(&mcp.Implementation{
+		Name:        "wikijs-mcp-server",
+		Title:       "WikiJS MCP Server",
+		Version:     opts.Version,
+		Description: "Search, read, and optionally edit pages in a WikiJS instance.",
+	}, &mcp.ServerOptions{
+		Instructions: instructions,
+		Logger:       logger,
+	})
+
+	h := &handlers{wiki: client}
+
+	readOnly := &mcp.ToolAnnotations{ReadOnlyHint: true}
+	mcp.AddTool(s, &mcp.Tool{
+		Name:        "search_pages",
+		Title:       "Search pages",
+		Description: "Search for pages in WikiJS by query string.",
+		Annotations: readOnly,
+	}, h.searchPages)
+	mcp.AddTool(s, &mcp.Tool{
+		Name:        "get_page_by_id",
+		Title:       "Get page by ID",
+		Description: "Get a WikiJS page by its numeric ID.",
+		Annotations: readOnly,
+	}, h.pageByID)
+	mcp.AddTool(s, &mcp.Tool{
+		Name:        "get_page_by_path",
+		Title:       "Get page by path",
+		Description: "Get a WikiJS page by its path and locale.",
+		Annotations: readOnly,
+	}, h.pageByPath)
+	mcp.AddTool(s, &mcp.Tool{
+		Name:        "get_all_pages",
+		Title:       "List pages",
+		Description: "List pages in WikiJS, optionally filtered by limit, locale, and tags.",
+		Annotations: readOnly,
+	}, h.listPages)
+
+	if opts.EnableEdit {
+		notDestructive := false
+		mcp.AddTool(s, &mcp.Tool{
+			Name:        "create_page",
+			Title:       "Create page",
+			Description: "Create a new WikiJS page.",
+			Annotations: &mcp.ToolAnnotations{DestructiveHint: &notDestructive},
+		}, h.createPage)
+		mcp.AddTool(s, &mcp.Tool{
+			Name:        "edit_page",
+			Title:       "Edit page",
+			Description: "Edit an existing WikiJS page by its numeric ID.",
+			Annotations: &mcp.ToolAnnotations{IdempotentHint: true},
+		}, h.editPage)
+	}
+
+	return s
+}

+ 258 - 0
internal/server/tools.go

@@ -0,0 +1,258 @@
+package server
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"regexp"
+	"strings"
+
+	"wikijs-mcp/internal/wikijs"
+
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+var localePattern = regexp.MustCompile(`^[a-z]{2}(-[A-Z]{2})?$`)
+
+type handlers struct {
+	wiki *wikijs.Client
+}
+
+type searchPagesInput struct {
+	Query  string `json:"query" jsonschema:"full-text search query"`
+	Path   string `json:"path,omitempty" jsonschema:"optional path to limit the search scope"`
+	Locale string `json:"locale,omitempty" jsonschema:"optional locale, for example en or de-DE"`
+}
+
+func (h *handlers) searchPages(ctx context.Context, _ *mcp.CallToolRequest, in searchPagesInput) (*mcp.CallToolResult, wikijs.SearchResults, error) {
+	if strings.TrimSpace(in.Query) == "" {
+		return nil, wikijs.SearchResults{}, errors.New("query must not be empty")
+	}
+	if err := validateLocale(in.Locale, false); err != nil {
+		return nil, wikijs.SearchResults{}, err
+	}
+
+	result, err := h.wiki.SearchPages(ctx, in.Query, in.Path, in.Locale)
+	if err != nil {
+		return nil, wikijs.SearchResults{}, err
+	}
+	return nil, result, nil
+}
+
+type pageByIDInput struct {
+	ID int `json:"id" jsonschema:"numeric ID of the page"`
+}
+
+func (h *handlers) pageByID(ctx context.Context, _ *mcp.CallToolRequest, in pageByIDInput) (*mcp.CallToolResult, any, error) {
+	if in.ID <= 0 {
+		return nil, nil, errors.New("id must be a positive integer")
+	}
+
+	page, err := h.wiki.PageByID(ctx, in.ID)
+	if err != nil {
+		return nil, nil, err
+	}
+	if page == nil {
+		return nullResult(), nil, nil
+	}
+	return nil, page, nil
+}
+
+type pageByPathInput struct {
+	Path   string `json:"path" jsonschema:"path of the page, for example /dev/go"`
+	Locale string `json:"locale" jsonschema:"locale of the page, for example en or de-DE"`
+}
+
+func (h *handlers) pageByPath(ctx context.Context, _ *mcp.CallToolRequest, in pageByPathInput) (*mcp.CallToolResult, any, error) {
+	if strings.TrimSpace(in.Path) == "" {
+		return nil, nil, errors.New("path must not be empty")
+	}
+	if err := validateLocale(in.Locale, true); err != nil {
+		return nil, nil, err
+	}
+
+	page, err := h.wiki.PageByPath(ctx, in.Path, in.Locale)
+	if err != nil {
+		return nil, nil, err
+	}
+	if page == nil {
+		return nullResult(), nil, nil
+	}
+	return nil, page, nil
+}
+
+type listPagesInput struct {
+	Limit  *int     `json:"limit,omitempty" jsonschema:"maximum number of pages to return"`
+	Locale string   `json:"locale,omitempty" jsonschema:"optional locale, for example en or de-DE"`
+	Tags   []string `json:"tags,omitempty" jsonschema:"optional tags to filter by"`
+}
+
+func (h *handlers) listPages(ctx context.Context, _ *mcp.CallToolRequest, in listPagesInput) (*mcp.CallToolResult, []wikijs.PageListItem, error) {
+	if in.Limit != nil && *in.Limit <= 0 {
+		return nil, nil, errors.New("limit must be a positive integer")
+	}
+	if err := validateLocale(in.Locale, false); err != nil {
+		return nil, nil, err
+	}
+
+	pages, err := h.wiki.ListPages(ctx, wikijs.ListOptions{
+		Limit:  in.Limit,
+		Locale: in.Locale,
+		Tags:   in.Tags,
+	})
+	if err != nil {
+		return nil, nil, err
+	}
+	return nil, pages, nil
+}
+
+type createPageInput struct {
+	Title       string   `json:"title" jsonschema:"title of the page"`
+	Content     string   `json:"content" jsonschema:"content of the page"`
+	Description string   `json:"description" jsonschema:"description of the page"`
+	Path        string   `json:"path" jsonschema:"path of the page, for example /dev/go"`
+	Editor      string   `json:"editor,omitempty" jsonschema:"editor type: markdown or visual (default markdown)"`
+	IsPublished *bool    `json:"isPublished,omitempty" jsonschema:"whether the page is published (default true)"`
+	IsPrivate   *bool    `json:"isPrivate,omitempty" jsonschema:"whether the page is private (default false)"`
+	Locale      string   `json:"locale,omitempty" jsonschema:"locale of the page (default en)"`
+	Tags        []string `json:"tags,omitempty" jsonschema:"tags for the page"`
+}
+
+type editPageInput struct {
+	ID          int      `json:"id" jsonschema:"numeric ID of the page to update"`
+	Title       string   `json:"title" jsonschema:"new title of the page"`
+	Content     string   `json:"content" jsonschema:"new content of the page"`
+	Description string   `json:"description" jsonschema:"new description of the page"`
+	Path        string   `json:"path" jsonschema:"path of the page, for example /dev/go"`
+	Editor      string   `json:"editor,omitempty" jsonschema:"editor type: markdown or visual (default markdown)"`
+	IsPublished *bool    `json:"isPublished,omitempty" jsonschema:"whether the page is published (default true)"`
+	IsPrivate   *bool    `json:"isPrivate,omitempty" jsonschema:"whether the page is private (default false)"`
+	Locale      string   `json:"locale,omitempty" jsonschema:"locale of the page (default en)"`
+	Tags        []string `json:"tags,omitempty" jsonschema:"tags for the page"`
+}
+
+type mutationOutput struct {
+	Succeeded bool   `json:"succeeded" jsonschema:"whether the mutation succeeded"`
+	Message   string `json:"message" jsonschema:"result message from WikiJS"`
+}
+
+func (h *handlers) createPage(ctx context.Context, _ *mcp.CallToolRequest, in createPageInput) (*mcp.CallToolResult, mutationOutput, error) {
+	if err := validatePageInput(in.Title, in.Path, in.Locale); err != nil {
+		return nil, mutationOutput{}, err
+	}
+
+	result, err := h.wiki.CreatePage(ctx, wikijs.CreatePageInput{
+		Title:       in.Title,
+		Content:     in.Content,
+		Description: in.Description,
+		Path:        in.Path,
+		Editor:      defaultValue(in.Editor, "markdown"),
+		IsPublished: boolValue(in.IsPublished, true),
+		IsPrivate:   boolValue(in.IsPrivate, false),
+		Locale:      defaultValue(in.Locale, "en"),
+		Tags:        nonNilStrings(in.Tags),
+	})
+	if err != nil {
+		return nil, mutationOutput{}, err
+	}
+	if !result.Succeeded {
+		return nil, mutationOutput{}, mutationError("create page", result)
+	}
+	return nil, mutationOutput{
+		Succeeded: true,
+		Message:   defaultValue(result.Message, "Page created successfully"),
+	}, nil
+}
+
+func (h *handlers) editPage(ctx context.Context, _ *mcp.CallToolRequest, in editPageInput) (*mcp.CallToolResult, mutationOutput, error) {
+	if in.ID <= 0 {
+		return nil, mutationOutput{}, errors.New("id must be a positive integer")
+	}
+	if err := validatePageInput(in.Title, in.Path, in.Locale); err != nil {
+		return nil, mutationOutput{}, err
+	}
+
+	result, err := h.wiki.UpdatePage(ctx, wikijs.UpdatePageInput{
+		ID:          in.ID,
+		Title:       in.Title,
+		Content:     in.Content,
+		Description: in.Description,
+		Path:        in.Path,
+		Editor:      defaultValue(in.Editor, "markdown"),
+		IsPublished: boolValue(in.IsPublished, true),
+		IsPrivate:   boolValue(in.IsPrivate, false),
+		Locale:      defaultValue(in.Locale, "en"),
+		Tags:        nonNilStrings(in.Tags),
+	})
+	if err != nil {
+		return nil, mutationOutput{}, err
+	}
+	if !result.Succeeded {
+		return nil, mutationOutput{}, mutationError("update page", result)
+	}
+	return nil, mutationOutput{
+		Succeeded: true,
+		Message:   defaultValue(result.Message, "Page updated successfully"),
+	}, nil
+}
+
+func validatePageInput(title, path, locale string) error {
+	if strings.TrimSpace(title) == "" {
+		return errors.New("title must not be empty")
+	}
+	if strings.TrimSpace(path) == "" {
+		return errors.New("path must not be empty")
+	}
+	return validateLocale(locale, false)
+}
+
+func validateLocale(locale string, required bool) error {
+	if locale == "" {
+		if required {
+			return errors.New("locale must not be empty")
+		}
+		return nil
+	}
+	if !localePattern.MatchString(locale) {
+		return fmt.Errorf("invalid locale %q: use a format like en or de-DE", locale)
+	}
+	return nil
+}
+
+func mutationError(action string, result wikijs.MutationResult) error {
+	message := defaultValue(result.Message, "unknown error")
+	if result.ErrorCode != 0 {
+		return fmt.Errorf("failed to %s: %s (error code %d)", action, message, result.ErrorCode)
+	}
+	return fmt.Errorf("failed to %s: %s", action, message)
+}
+
+func defaultValue(value, fallback string) string {
+	if value == "" {
+		return fallback
+	}
+	return value
+}
+
+func boolValue(value *bool, fallback bool) bool {
+	if value == nil {
+		return fallback
+	}
+	return *value
+}
+
+func nonNilStrings(values []string) []string {
+	if values == nil {
+		return []string{}
+	}
+	return values
+}
+
+// nullResult preserves the original server's "null" response for a page that
+// does not exist. A typed nil page would be coerced to an empty object by the
+// MCP SDK's output schema handling.
+func nullResult() *mcp.CallToolResult {
+	return &mcp.CallToolResult{
+		Content: []mcp.Content{&mcp.TextContent{Text: "null"}},
+	}
+}

+ 508 - 0
internal/server/tools_test.go

@@ -0,0 +1,508 @@
+package server
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"slices"
+	"strings"
+	"sync"
+	"testing"
+
+	"wikijs-mcp/internal/wikijs"
+
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+type capturedRequest struct {
+	Query     string         `json:"query"`
+	Variables map[string]any `json:"variables"`
+}
+
+type fakeWiki struct {
+	t        *testing.T
+	mu       sync.Mutex
+	requests []capturedRequest
+}
+
+func (f *fakeWiki) handle(w http.ResponseWriter, r *http.Request) {
+	f.t.Helper()
+	if got, want := r.Header.Get("Authorization"), "Bearer test-key"; got != want {
+		f.t.Errorf("Authorization = %q, want %q", got, want)
+	}
+
+	var req capturedRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		f.t.Errorf("decode request: %v", err)
+		http.Error(w, "bad request", http.StatusBadRequest)
+		return
+	}
+	f.mu.Lock()
+	f.requests = append(f.requests, req)
+	f.mu.Unlock()
+
+	w.Header().Set("Content-Type", "application/json")
+	switch {
+	case strings.Contains(req.Query, "query SearchPages"):
+		fmt.Fprint(w, `{"data":{"pages":{"search":{"results":[
+			{"title":"MCP","description":"protocol","path":"/mcp","locale":"en"}
+		],"suggestions":["mcp"],"totalHits":1}}}}`)
+
+	case strings.Contains(req.Query, "query GetPageById"):
+		id, _ := req.Variables["id"].(float64)
+		if id == 404 {
+			fmt.Fprint(w, `{"data":{"pages":{"single":null}}}`)
+			return
+		}
+		fmt.Fprintf(w, `{"data":{"pages":{"single":{
+			"id":%d,"path":"/dev/go","title":"Go","description":"desc",
+			"tags":[{"tag":"go","title":"Go"}],"content":"body",
+			"createdAt":"c","updatedAt":"u","locale":"de"}}}}`, int(id))
+
+	case strings.Contains(req.Query, "query GetPageByPath"):
+		path, _ := req.Variables["path"].(string)
+		locale, _ := req.Variables["locale"].(string)
+		fmt.Fprintf(w, `{"data":{"pages":{"singleByPath":{
+			"id":7,"path":%q,"title":"Start","description":"desc",
+			"tags":[],"content":"body","createdAt":"c","updatedAt":"u","locale":%q}}}}`, path, locale)
+
+	case strings.Contains(req.Query, "query GetAllPages"):
+		fmt.Fprint(w, `{"data":{"pages":{"list":[
+			{"id":1,"path":"/a","locale":"de","title":"A","description":"a",
+			 "createdAt":"c","updatedAt":"u","tags":["go"]}
+		]}}}`)
+
+	case strings.Contains(req.Query, "mutation CreatePage"):
+		title, _ := req.Variables["title"].(string)
+		if title == "fail" {
+			fmt.Fprint(w, `{"data":{"pages":{"create":{"responseResult":{
+				"succeeded":false,"errorCode":6001,"slug":"","message":"Nope"}}}}}`)
+			return
+		}
+		fmt.Fprint(w, `{"data":{"pages":{"create":{"responseResult":{
+			"succeeded":true,"errorCode":0,"slug":"new","message":"Page created"}}}}}`)
+
+	case strings.Contains(req.Query, "mutation UpdatePage"):
+		id, _ := req.Variables["id"].(float64)
+		if id == 404 {
+			fmt.Fprint(w, `{"data":{"pages":{"update":{"responseResult":{
+				"succeeded":false,"errorCode":6001,"slug":"","message":"Page not found"}}}}}`)
+			return
+		}
+		fmt.Fprint(w, `{"data":{"pages":{"update":{"responseResult":{
+			"succeeded":true,"errorCode":0,"slug":"","message":"Page updated"}}}}}`)
+
+	default:
+		f.t.Errorf("unexpected query: %s", req.Query)
+		http.Error(w, "unknown operation", http.StatusBadRequest)
+	}
+}
+
+func (f *fakeWiki) lastContaining(t *testing.T, fragment string) capturedRequest {
+	t.Helper()
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	for i := len(f.requests) - 1; i >= 0; i-- {
+		if strings.Contains(f.requests[i].Query, fragment) {
+			return f.requests[i]
+		}
+	}
+	t.Fatalf("no request containing %q", fragment)
+	return capturedRequest{}
+}
+
+func newTestSession(t *testing.T, enableEdit bool) (*mcp.ClientSession, *fakeWiki) {
+	t.Helper()
+
+	fake := &fakeWiki{t: t}
+	wikiSrv := httptest.NewServer(http.HandlerFunc(fake.handle))
+	t.Cleanup(wikiSrv.Close)
+
+	wikiClient, err := wikijs.New(wikiSrv.URL, "test-key")
+	if err != nil {
+		t.Fatalf("wikijs.New() error = %v", err)
+	}
+
+	srv := New(wikiClient, Options{
+		Version:    "test",
+		EnableEdit: enableEdit,
+		Logger:     slog.New(slog.DiscardHandler),
+	})
+
+	st, ct := mcp.NewInMemoryTransports()
+	ctx := context.Background()
+	ss, err := srv.Connect(ctx, st, nil)
+	if err != nil {
+		t.Fatalf("server connect: %v", err)
+	}
+	t.Cleanup(func() { _ = ss.Close() })
+
+	client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0"}, nil)
+	cs, err := client.Connect(ctx, ct, nil)
+	if err != nil {
+		t.Fatalf("client connect: %v", err)
+	}
+	t.Cleanup(func() { _ = cs.Close() })
+
+	return cs, fake
+}
+
+func callTool(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any) *mcp.CallToolResult {
+	t.Helper()
+	res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
+	if err != nil {
+		t.Fatalf("CallTool(%s) error = %v", name, err)
+	}
+	return res
+}
+
+func toolText(t *testing.T, res *mcp.CallToolResult) string {
+	t.Helper()
+	if len(res.Content) == 0 {
+		t.Fatal("tool result has no content")
+	}
+	text, ok := res.Content[0].(*mcp.TextContent)
+	if !ok {
+		t.Fatalf("content type = %T, want *mcp.TextContent", res.Content[0])
+	}
+	return text.Text
+}
+
+func TestToolListWithoutEdit(t *testing.T) {
+	cs, _ := newTestSession(t, false)
+
+	res, err := cs.ListTools(context.Background(), nil)
+	if err != nil {
+		t.Fatalf("ListTools() error = %v", err)
+	}
+	got := toolNames(res.Tools)
+	want := []string{"search_pages", "get_page_by_id", "get_page_by_path", "get_all_pages"}
+	if !equalNames(got, want) {
+		t.Errorf("tools = %v, want %v", got, want)
+	}
+}
+
+func TestToolListWithEdit(t *testing.T) {
+	cs, _ := newTestSession(t, true)
+
+	res, err := cs.ListTools(context.Background(), nil)
+	if err != nil {
+		t.Fatalf("ListTools() error = %v", err)
+	}
+	got := toolNames(res.Tools)
+	want := []string{"search_pages", "get_page_by_id", "get_page_by_path", "get_all_pages", "create_page", "edit_page"}
+	if !equalNames(got, want) {
+		t.Errorf("tools = %v, want %v", got, want)
+	}
+}
+
+func toolNames(tools []*mcp.Tool) []string {
+	names := make([]string, len(tools))
+	for i, tool := range tools {
+		names[i] = tool.Name
+	}
+	return names
+}
+
+func equalNames(got, want []string) bool {
+	got = slices.Clone(got)
+	want = slices.Clone(want)
+	slices.Sort(got)
+	slices.Sort(want)
+	return slices.Equal(got, want)
+}
+
+func TestSearchPagesTool(t *testing.T) {
+	cs, fake := newTestSession(t, false)
+
+	res := callTool(t, cs, "search_pages", map[string]any{"query": "mcp"})
+	if res.IsError {
+		t.Fatalf("IsError = true: %s", toolText(t, res))
+	}
+	if res.StructuredContent == nil {
+		t.Error("StructuredContent = nil")
+	}
+
+	var out wikijs.SearchResults
+	if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
+		t.Fatalf("decode result: %v", err)
+	}
+	if out.TotalHits != 1 || len(out.Results) != 1 {
+		t.Fatalf("result = %+v", out)
+	}
+	if !strings.HasSuffix(out.Results[0].URL, "/mcp") {
+		t.Errorf("URL = %q", out.Results[0].URL)
+	}
+
+	req := fake.lastContaining(t, "query SearchPages")
+	if _, ok := req.Variables["path"]; ok {
+		t.Errorf("path variable present: %v", req.Variables["path"])
+	}
+}
+
+func TestSearchPagesValidation(t *testing.T) {
+	cs, _ := newTestSession(t, false)
+
+	tests := []struct {
+		name string
+		args map[string]any
+		want string
+	}{
+		{name: "empty query", args: map[string]any{"query": "  "}, want: "query must not be empty"},
+		{name: "bad locale", args: map[string]any{"query": "x", "locale": "english"}, want: "invalid locale"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			res := callTool(t, cs, "search_pages", tt.args)
+			if !res.IsError {
+				t.Fatal("IsError = false, want true")
+			}
+			if got := toolText(t, res); !strings.Contains(got, tt.want) {
+				t.Errorf("text = %q, want substring %q", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestGetPageByIDTool(t *testing.T) {
+	cs, _ := newTestSession(t, false)
+
+	res := callTool(t, cs, "get_page_by_id", map[string]any{"id": 42})
+	if res.IsError {
+		t.Fatalf("IsError = true: %s", toolText(t, res))
+	}
+	var page wikijs.Page
+	if err := json.Unmarshal([]byte(toolText(t, res)), &page); err != nil {
+		t.Fatalf("decode page: %v", err)
+	}
+	if page.ID != 42 || page.Title != "Go" {
+		t.Errorf("page = %+v", page)
+	}
+	if !strings.HasSuffix(page.URL, "/de/dev/go") {
+		t.Errorf("URL = %q", page.URL)
+	}
+}
+
+func TestGetPageByIDNotFound(t *testing.T) {
+	cs, _ := newTestSession(t, false)
+
+	res := callTool(t, cs, "get_page_by_id", map[string]any{"id": 404})
+	if res.IsError {
+		t.Fatalf("IsError = true: %s", toolText(t, res))
+	}
+	if got := strings.TrimSpace(toolText(t, res)); got != "null" {
+		t.Errorf("text = %q, want null", got)
+	}
+}
+
+func TestGetPageByIDInvalid(t *testing.T) {
+	cs, _ := newTestSession(t, false)
+
+	res := callTool(t, cs, "get_page_by_id", map[string]any{"id": 0})
+	if !res.IsError {
+		t.Fatal("IsError = false, want true")
+	}
+	if got := toolText(t, res); !strings.Contains(got, "positive") {
+		t.Errorf("text = %q", got)
+	}
+}
+
+func TestGetPageByPathTool(t *testing.T) {
+	cs, _ := newTestSession(t, false)
+
+	res := callTool(t, cs, "get_page_by_path", map[string]any{"path": "/start", "locale": "de-DE"})
+	if res.IsError {
+		t.Fatalf("IsError = true: %s", toolText(t, res))
+	}
+	var page wikijs.Page
+	if err := json.Unmarshal([]byte(toolText(t, res)), &page); err != nil {
+		t.Fatalf("decode page: %v", err)
+	}
+	if page.ID != 7 {
+		t.Errorf("page = %+v", page)
+	}
+	if !strings.HasSuffix(page.URL, "/de-DE/start") {
+		t.Errorf("URL = %q", page.URL)
+	}
+}
+
+func TestGetPageByPathRequiresLocale(t *testing.T) {
+	cs, _ := newTestSession(t, false)
+
+	res := callTool(t, cs, "get_page_by_path", map[string]any{"path": "/start"})
+	if !res.IsError {
+		t.Fatal("IsError = false, want true")
+	}
+}
+
+func TestListPagesTool(t *testing.T) {
+	cs, fake := newTestSession(t, false)
+
+	res := callTool(t, cs, "get_all_pages", map[string]any{
+		"limit":  10,
+		"locale": "de",
+		"tags":   []string{"go"},
+	})
+	if res.IsError {
+		t.Fatalf("IsError = true: %s", toolText(t, res))
+	}
+	var pages []wikijs.PageListItem
+	if err := json.Unmarshal([]byte(toolText(t, res)), &pages); err != nil {
+		t.Fatalf("decode pages: %v", err)
+	}
+	if len(pages) != 1 || pages[0].ID != 1 {
+		t.Errorf("pages = %+v", pages)
+	}
+
+	req := fake.lastContaining(t, "query GetAllPages")
+	if got := req.Variables["limit"]; got != float64(10) {
+		t.Errorf("variables[limit] = %v", got)
+	}
+	if got := req.Variables["locale"]; got != "de" {
+		t.Errorf("variables[locale] = %v", got)
+	}
+}
+
+func TestListPagesValidation(t *testing.T) {
+	cs, _ := newTestSession(t, false)
+
+	res := callTool(t, cs, "get_all_pages", map[string]any{"limit": 0})
+	if !res.IsError {
+		t.Fatal("IsError = false, want true")
+	}
+	if got := toolText(t, res); !strings.Contains(got, "limit must be a positive integer") {
+		t.Errorf("text = %q", got)
+	}
+}
+
+func TestCreatePageDefaults(t *testing.T) {
+	cs, fake := newTestSession(t, true)
+
+	res := callTool(t, cs, "create_page", map[string]any{
+		"title":       "New",
+		"content":     "body",
+		"description": "desc",
+		"path":        "/new",
+	})
+	if res.IsError {
+		t.Fatalf("IsError = true: %s", toolText(t, res))
+	}
+
+	var out mutationOutput
+	if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
+		t.Fatalf("decode output: %v", err)
+	}
+	if !out.Succeeded || out.Message != "Page created" {
+		t.Errorf("output = %+v", out)
+	}
+
+	req := fake.lastContaining(t, "mutation CreatePage")
+	want := map[string]any{
+		"editor":      "markdown",
+		"isPublished": true,
+		"isPrivate":   false,
+		"locale":      "en",
+	}
+	for key, value := range want {
+		if got := req.Variables[key]; got != value {
+			t.Errorf("variables[%s] = %v, want %v", key, got, value)
+		}
+	}
+	tags, ok := req.Variables["tags"].([]any)
+	if !ok || len(tags) != 0 {
+		t.Errorf("variables[tags] = %v, want []", req.Variables["tags"])
+	}
+}
+
+func TestCreatePageFailure(t *testing.T) {
+	cs, _ := newTestSession(t, true)
+
+	res := callTool(t, cs, "create_page", map[string]any{
+		"title":       "fail",
+		"content":     "body",
+		"description": "desc",
+		"path":        "/new",
+	})
+	if !res.IsError {
+		t.Fatal("IsError = false, want true")
+	}
+	text := toolText(t, res)
+	if !strings.Contains(text, "failed to create page") || !strings.Contains(text, "Nope") || !strings.Contains(text, "6001") {
+		t.Errorf("text = %q", text)
+	}
+}
+
+func TestEditPageDefaults(t *testing.T) {
+	cs, fake := newTestSession(t, true)
+
+	res := callTool(t, cs, "edit_page", map[string]any{
+		"id":          7,
+		"title":       "Updated",
+		"content":     "body",
+		"description": "desc",
+		"path":        "/start",
+	})
+	if res.IsError {
+		t.Fatalf("IsError = true: %s", toolText(t, res))
+	}
+
+	var out mutationOutput
+	if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
+		t.Fatalf("decode output: %v", err)
+	}
+	if !out.Succeeded || out.Message != "Page updated" {
+		t.Errorf("output = %+v", out)
+	}
+
+	req := fake.lastContaining(t, "mutation UpdatePage")
+	if got := req.Variables["id"]; got != float64(7) {
+		t.Errorf("variables[id] = %v", got)
+	}
+	if got := req.Variables["editor"]; got != "markdown" {
+		t.Errorf("variables[editor] = %v", got)
+	}
+}
+
+func TestEditPageNotFound(t *testing.T) {
+	cs, _ := newTestSession(t, true)
+
+	res := callTool(t, cs, "edit_page", map[string]any{
+		"id":          404,
+		"title":       "Updated",
+		"content":     "body",
+		"description": "desc",
+		"path":        "/start",
+	})
+	if !res.IsError {
+		t.Fatal("IsError = false, want true")
+	}
+	text := toolText(t, res)
+	if !strings.Contains(text, "failed to update page") || !strings.Contains(text, "Page not found") {
+		t.Errorf("text = %q", text)
+	}
+}
+
+func TestEditToolsHiddenWithoutEditMode(t *testing.T) {
+	cs, _ := newTestSession(t, false)
+
+	_, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
+		Name:      "create_page",
+		Arguments: map[string]any{"title": "x", "content": "", "description": "", "path": "/x"},
+	})
+	if err == nil {
+		t.Fatal("CallTool(create_page) error = nil, want tool not found")
+	}
+}
+
+func TestToolOutputIsJSON(t *testing.T) {
+	cs, _ := newTestSession(t, false)
+
+	res := callTool(t, cs, "get_all_pages", nil)
+	var decoded any
+	if err := json.Unmarshal([]byte(toolText(t, res)), &decoded); err != nil {
+		t.Fatalf("output is not JSON: %v", err)
+	}
+}

+ 190 - 0
internal/transport/http.go

@@ -0,0 +1,190 @@
+// Package transport wires the MCP server to stdio or streamable HTTP.
+package transport
+
+import (
+	"context"
+	"crypto/subtle"
+	"errors"
+	"fmt"
+	"log/slog"
+	"net"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// DefaultSessionTimeout closes idle HTTP sessions after 30 minutes.
+const DefaultSessionTimeout = 30 * time.Minute
+
+// HTTPOptions configures the streamable HTTP transport.
+type HTTPOptions struct {
+	Addr           string
+	Path           string
+	Stateless      bool
+	AuthToken      string
+	SessionTimeout time.Duration
+	Logger         *slog.Logger
+	ProtocolLogger *slog.Logger
+	NewServer      func(*http.Request) *mcp.Server
+}
+
+// NewHTTPHandler returns an HTTP handler that serves the MCP endpoint at
+// opts.Path and a liveness endpoint at /healthz. If AuthToken is set, every
+// request except /healthz requires a matching bearer token.
+func NewHTTPHandler(opts HTTPOptions) http.Handler {
+	if opts.NewServer == nil {
+		panic("transport: NewHTTPHandler requires NewServer")
+	}
+	logger := opts.Logger
+	if logger == nil {
+		logger = slog.New(slog.DiscardHandler)
+	}
+	if opts.Path == "" {
+		opts.Path = "/mcp"
+	}
+	if opts.SessionTimeout <= 0 {
+		opts.SessionTimeout = DefaultSessionTimeout
+	}
+	protocolLogger := opts.ProtocolLogger
+	if protocolLogger == nil {
+		protocolLogger = logger
+	}
+
+	mcpHandler := mcp.NewStreamableHTTPHandler(opts.NewServer, &mcp.StreamableHTTPOptions{
+		Stateless:      opts.Stateless,
+		Logger:         protocolLogger,
+		SessionTimeout: opts.SessionTimeout,
+	})
+
+	mux := http.NewServeMux()
+	mux.Handle(opts.Path, mcpHandler)
+	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.Header().Set("Cache-Control", "no-store")
+		_, _ = w.Write([]byte(`{"status":"ok"}`))
+	})
+
+	var handler http.Handler = mux
+	if opts.AuthToken != "" {
+		handler = bearerAuth(opts.AuthToken, handler)
+	}
+	return logRequests(logger, handler)
+}
+
+// ServeHTTP runs the streamable HTTP transport until ctx is cancelled.
+func ServeHTTP(ctx context.Context, opts HTTPOptions) error {
+	logger := opts.Logger
+	if logger == nil {
+		logger = slog.New(slog.DiscardHandler)
+	}
+
+	ln, err := net.Listen("tcp", opts.Addr)
+	if err != nil {
+		return fmt.Errorf("listen on %s: %w", opts.Addr, err)
+	}
+	if opts.AuthToken == "" && !isLoopbackListener(ln.Addr()) {
+		_ = ln.Close()
+		return fmt.Errorf("refusing to serve HTTP on non-loopback address %s without MCP_AUTH_TOKEN", ln.Addr())
+	}
+
+	srv := &http.Server{
+		Handler:           NewHTTPHandler(opts),
+		ReadHeaderTimeout: 10 * time.Second,
+		IdleTimeout:       2 * time.Minute,
+	}
+
+	logger.Info("WikiJS MCP server listening",
+		"transport", "http",
+		"addr", ln.Addr().String(),
+		"path", opts.Path,
+		"stateless", opts.Stateless,
+		"auth", opts.AuthToken != "",
+	)
+
+	errCh := make(chan error, 1)
+	go func() {
+		errCh <- srv.Serve(ln)
+	}()
+
+	select {
+	case <-ctx.Done():
+		shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+		defer cancel()
+		if err := srv.Shutdown(shutdownCtx); err != nil {
+			logger.Warn("HTTP shutdown incomplete, forcing close", "error", err)
+			_ = srv.Close()
+		}
+		return nil
+	case err := <-errCh:
+		if errors.Is(err, http.ErrServerClosed) {
+			return nil
+		}
+		return err
+	}
+}
+
+func isLoopbackListener(addr net.Addr) bool {
+	tcp, ok := addr.(*net.TCPAddr)
+	return ok && tcp.IP.IsLoopback()
+}
+
+func bearerAuth(token string, next http.Handler) http.Handler {
+	expected := []byte(token)
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path == "/healthz" {
+			next.ServeHTTP(w, r)
+			return
+		}
+		scheme, got, ok := strings.Cut(r.Header.Get("Authorization"), " ")
+		if !ok || !strings.EqualFold(scheme, "Bearer") || subtle.ConstantTimeCompare([]byte(got), expected) != 1 {
+			w.Header().Set("WWW-Authenticate", `Bearer realm="wikijs-mcp"`)
+			http.Error(w, "unauthorized", http.StatusUnauthorized)
+			return
+		}
+		next.ServeHTTP(w, r)
+	})
+}
+
+type statusWriter struct {
+	http.ResponseWriter
+	status int
+}
+
+func (w *statusWriter) WriteHeader(code int) {
+	if w.status == 0 {
+		w.status = code
+	}
+	w.ResponseWriter.WriteHeader(code)
+}
+
+func (w *statusWriter) Write(b []byte) (int, error) {
+	if w.status == 0 {
+		w.status = http.StatusOK
+	}
+	return w.ResponseWriter.Write(b)
+}
+
+// Unwrap lets http.ResponseController reach the underlying writer, which is
+// required for HTTP streaming.
+func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
+
+func logRequests(logger *slog.Logger, next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		start := time.Now()
+		sw := &statusWriter{ResponseWriter: w}
+		next.ServeHTTP(sw, r)
+		status := sw.status
+		if status == 0 {
+			status = http.StatusOK
+		}
+		logger.Debug("http request",
+			"method", r.Method,
+			"path", r.URL.Path,
+			"status", status,
+			"duration", time.Since(start),
+			"remote", r.RemoteAddr,
+		)
+	})
+}

+ 233 - 0
internal/transport/http_test.go

@@ -0,0 +1,233 @@
+package transport
+
+import (
+	"context"
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+
+	"wikijs-mcp/internal/server"
+	"wikijs-mcp/internal/wikijs"
+
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+func testLogger() *slog.Logger {
+	return slog.New(slog.DiscardHandler)
+}
+
+func newTestServer(t *testing.T, wikiClient *wikijs.Client) *mcp.Server {
+	t.Helper()
+	return server.New(wikiClient, server.Options{Version: "test", Logger: testLogger()})
+}
+
+func newTestHandler(t *testing.T, authToken string) http.Handler {
+	t.Helper()
+
+	fakeWiki := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"data":{"pages":{"single":{"id":1,"path":"/start","title":"Start","description":"","tags":[],"content":"body","createdAt":"","updatedAt":"","locale":"en"}}}}`))
+	}))
+	t.Cleanup(fakeWiki.Close)
+
+	wikiClient, err := wikijs.New(fakeWiki.URL, "key")
+	if err != nil {
+		t.Fatalf("wikijs.New() error = %v", err)
+	}
+	srv := newTestServer(t, wikiClient)
+
+	return NewHTTPHandler(HTTPOptions{
+		Path:      "/mcp",
+		AuthToken: authToken,
+		Logger:    testLogger(),
+		NewServer: func(*http.Request) *mcp.Server { return srv },
+	})
+}
+
+func TestHealthzIsOpen(t *testing.T) {
+	ts := httptest.NewServer(newTestHandler(t, "secret"))
+	t.Cleanup(ts.Close)
+
+	resp, err := http.Get(ts.URL + "/healthz")
+	if err != nil {
+		t.Fatalf("GET /healthz: %v", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		t.Errorf("status = %d, want 200", resp.StatusCode)
+	}
+	var body map[string]string
+	if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+		t.Fatalf("decode body: %v", err)
+	}
+	if body["status"] != "ok" {
+		t.Errorf("body = %v", body)
+	}
+}
+
+func TestMCPRequiresBearerToken(t *testing.T) {
+	ts := httptest.NewServer(newTestHandler(t, "secret"))
+	t.Cleanup(ts.Close)
+
+	tests := []struct {
+		name   string
+		header string
+		want   int
+	}{
+		{name: "missing", want: http.StatusUnauthorized},
+		{name: "wrong token", header: "Bearer nope", want: http.StatusUnauthorized},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			req, _ := http.NewRequest(http.MethodPost, ts.URL+"/mcp", strings.NewReader(`{}`))
+			req.Header.Set("Content-Type", "application/json")
+			if tt.header != "" {
+				req.Header.Set("Authorization", tt.header)
+			}
+			resp, err := http.DefaultClient.Do(req)
+			if err != nil {
+				t.Fatalf("POST /mcp: %v", err)
+			}
+			defer resp.Body.Close()
+
+			if resp.StatusCode != tt.want {
+				t.Errorf("status = %d, want %d", resp.StatusCode, tt.want)
+			}
+			if got := resp.Header.Get("WWW-Authenticate"); !strings.Contains(got, "Bearer") {
+				t.Errorf("WWW-Authenticate = %q", got)
+			}
+		})
+	}
+}
+
+func TestBearerSchemeIsCaseInsensitive(t *testing.T) {
+	ts := httptest.NewServer(newTestHandler(t, "secret"))
+	t.Cleanup(ts.Close)
+
+	req, _ := http.NewRequest(http.MethodPost, ts.URL+"/mcp", strings.NewReader(`{}`))
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Authorization", "bearer secret")
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		t.Fatalf("POST /mcp: %v", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode == http.StatusUnauthorized {
+		t.Error("lowercase bearer scheme was rejected")
+	}
+}
+
+func TestMCPWithBearerToken(t *testing.T) {
+	ts := httptest.NewServer(newTestHandler(t, "secret"))
+	t.Cleanup(ts.Close)
+
+	httpClient := &http.Client{Transport: &bearerRoundTripper{token: "secret"}}
+	cs := connectHTTP(t, ts.URL+"/mcp", httpClient)
+
+	res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
+		Name:      "get_page_by_id",
+		Arguments: map[string]any{"id": 1},
+	})
+	if err != nil {
+		t.Fatalf("CallTool() error = %v", err)
+	}
+	if res.IsError {
+		t.Fatalf("IsError = true: %v", res.Content)
+	}
+}
+
+func TestMCPWithoutAuthToken(t *testing.T) {
+	ts := httptest.NewServer(newTestHandler(t, ""))
+	t.Cleanup(ts.Close)
+
+	cs := connectHTTP(t, ts.URL+"/mcp", ts.Client())
+
+	res, err := cs.ListTools(context.Background(), nil)
+	if err != nil {
+		t.Fatalf("ListTools() error = %v", err)
+	}
+	if len(res.Tools) != 4 {
+		t.Errorf("tools = %d, want 4", len(res.Tools))
+	}
+}
+
+func TestServeHTTPRefusesNonLoopbackWithoutAuth(t *testing.T) {
+	err := ServeHTTP(context.Background(), HTTPOptions{
+		Addr:      "0.0.0.0:0",
+		Path:      "/mcp",
+		Logger:    testLogger(),
+		NewServer: func(*http.Request) *mcp.Server { return newBareServer() },
+	})
+	if err == nil {
+		t.Fatal("ServeHTTP() error = nil, want refusal")
+	}
+	if !strings.Contains(err.Error(), "non-loopback") {
+		t.Errorf("error = %q, want non-loopback refusal", err)
+	}
+}
+
+func TestServeHTTPShutdown(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	done := make(chan error, 1)
+	go func() {
+		done <- ServeHTTP(ctx, HTTPOptions{
+			Addr:      "127.0.0.1:0",
+			Path:      "/mcp",
+			Logger:    testLogger(),
+			NewServer: func(*http.Request) *mcp.Server { return newBareServer() },
+		})
+	}()
+
+	time.Sleep(100 * time.Millisecond)
+	cancel()
+
+	select {
+	case err := <-done:
+		if err != nil {
+			t.Fatalf("ServeHTTP() error = %v", err)
+		}
+	case <-time.After(5 * time.Second):
+		t.Fatal("ServeHTTP() did not stop after cancellation")
+	}
+}
+
+func newBareServer() *mcp.Server {
+	return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0"}, nil)
+}
+
+func connectHTTP(t *testing.T, endpoint string, httpClient *http.Client) *mcp.ClientSession {
+	t.Helper()
+
+	client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0"}, nil)
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	t.Cleanup(cancel)
+
+	cs, err := client.Connect(ctx, &mcp.StreamableClientTransport{
+		Endpoint:             endpoint,
+		HTTPClient:           httpClient,
+		DisableStandaloneSSE: true,
+	}, nil)
+	if err != nil {
+		t.Fatalf("client connect: %v", err)
+	}
+	t.Cleanup(func() { _ = cs.Close() })
+	return cs
+}
+
+type bearerRoundTripper struct {
+	token string
+}
+
+func (t *bearerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+	req = req.Clone(req.Context())
+	req.Header.Set("Authorization", "Bearer "+t.token)
+	return http.DefaultTransport.RoundTrip(req)
+}

+ 431 - 0
internal/wikijs/client.go

@@ -0,0 +1,431 @@
+package wikijs
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+)
+
+// maxResponseBytes bounds the response body read from WikiJS.
+const maxResponseBytes = 32 << 20
+
+// Client talks to the WikiJS GraphQL API.
+type Client struct {
+	baseURL   string
+	endpoint  string
+	apiKey    string
+	userAgent string
+	http      *http.Client
+	logger    *slog.Logger
+}
+
+// Option customizes a [Client].
+type Option func(*Client)
+
+// WithHTTPClient sets the HTTP client used for GraphQL requests.
+func WithHTTPClient(hc *http.Client) Option {
+	return func(c *Client) {
+		if hc != nil {
+			c.http = hc
+		}
+	}
+}
+
+// WithLogger enables debug logging of GraphQL operations.
+func WithLogger(logger *slog.Logger) Option {
+	return func(c *Client) {
+		if logger != nil {
+			c.logger = logger
+		}
+	}
+}
+
+// WithUserAgent overrides the User-Agent sent to WikiJS.
+func WithUserAgent(userAgent string) Option {
+	return func(c *Client) {
+		if userAgent != "" {
+			c.userAgent = userAgent
+		}
+	}
+}
+
+// New creates a client for a WikiJS base URL. The URL may end with /graphql;
+// it is normalized so that the GraphQL endpoint is always addressed once.
+func New(rawBaseURL, apiKey string, opts ...Option) (*Client, error) {
+	base := strings.TrimRight(strings.TrimSpace(rawBaseURL), "/")
+	u, err := url.Parse(base)
+	if err != nil {
+		return nil, fmt.Errorf("parse WikiJS URL: %w", err)
+	}
+	if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
+		return nil, fmt.Errorf("WikiJS URL must be an absolute http(s) URL, got %q", rawBaseURL)
+	}
+	if u.RawQuery != "" || u.Fragment != "" {
+		return nil, fmt.Errorf("WikiJS URL must not contain a query or fragment, got %q", rawBaseURL)
+	}
+
+	endpoint := base
+	if strings.HasSuffix(base, "/graphql") {
+		base = strings.TrimSuffix(base, "/graphql")
+	} else {
+		endpoint += "/graphql"
+	}
+
+	c := &Client{
+		baseURL:   base,
+		endpoint:  endpoint,
+		apiKey:    apiKey,
+		userAgent: "wikijs-mcp",
+		http:      http.DefaultClient,
+		logger:    slog.New(slog.DiscardHandler),
+	}
+	for _, opt := range opts {
+		opt(c)
+	}
+	return c, nil
+}
+
+// SearchPages runs a WikiJS full-text search. path and locale are optional.
+func (c *Client) SearchPages(ctx context.Context, query, path, locale string) (SearchResults, error) {
+	vars := map[string]any{"query": query}
+	putString(vars, "path", path)
+	putString(vars, "locale", locale)
+
+	var data struct {
+		Pages *struct {
+			Search *struct {
+				Results     []*SearchHit `json:"results"`
+				Suggestions []string     `json:"suggestions"`
+				TotalHits   int          `json:"totalHits"`
+			} `json:"search"`
+		} `json:"pages"`
+	}
+	if err := c.do(ctx, "SearchPages", searchPagesQuery, vars, &data); err != nil {
+		return SearchResults{}, err
+	}
+
+	out := SearchResults{Results: []SearchHit{}, Suggestions: []string{}}
+	if data.Pages == nil || data.Pages.Search == nil {
+		return out, nil
+	}
+	for _, hit := range data.Pages.Search.Results {
+		if hit == nil {
+			continue
+		}
+		hit.URL = c.pageURL(hit.Path, hit.Locale)
+		out.Results = append(out.Results, *hit)
+	}
+	out.Suggestions = append(out.Suggestions, data.Pages.Search.Suggestions...)
+	out.TotalHits = data.Pages.Search.TotalHits
+	return out, nil
+}
+
+// PageByID returns a page by its numeric ID. It returns a nil page without an
+// error when WikiJS does not know the ID.
+func (c *Client) PageByID(ctx context.Context, id int) (*Page, error) {
+	var data struct {
+		Pages *struct {
+			Single *Page `json:"single"`
+		} `json:"pages"`
+	}
+	if err := c.do(ctx, "GetPageById", pageByIDQuery, map[string]any{"id": id}, &data); err != nil {
+		return nil, err
+	}
+	if data.Pages == nil || data.Pages.Single == nil {
+		return nil, nil
+	}
+	return c.decorate(data.Pages.Single), nil
+}
+
+// PageByPath returns a page by its path and locale. It returns a nil page
+// without an error when the page does not exist.
+func (c *Client) PageByPath(ctx context.Context, path, locale string) (*Page, error) {
+	vars := map[string]any{"path": path, "locale": locale}
+
+	var data struct {
+		Pages *struct {
+			Single *Page `json:"singleByPath"`
+		} `json:"pages"`
+	}
+	if err := c.do(ctx, "GetPageByPath", pageByPathQuery, vars, &data); err != nil {
+		return nil, err
+	}
+	if data.Pages == nil || data.Pages.Single == nil {
+		return nil, nil
+	}
+	return c.decorate(data.Pages.Single), nil
+}
+
+// ListPages returns pages, optionally filtered by limit, locale, and tags.
+func (c *Client) ListPages(ctx context.Context, opts ListOptions) ([]PageListItem, error) {
+	vars := map[string]any{}
+	if opts.Limit != nil {
+		vars["limit"] = *opts.Limit
+	}
+	putString(vars, "locale", opts.Locale)
+	if len(opts.Tags) > 0 {
+		vars["tags"] = opts.Tags
+	}
+
+	var data struct {
+		Pages *struct {
+			List []*PageListItem `json:"list"`
+		} `json:"pages"`
+	}
+	if err := c.do(ctx, "GetAllPages", listPagesQuery, vars, &data); err != nil {
+		return nil, err
+	}
+
+	out := []PageListItem{}
+	if data.Pages == nil {
+		return out, nil
+	}
+	for _, page := range data.Pages.List {
+		if page == nil {
+			continue
+		}
+		page.URL = c.pageURL(page.Path, page.Locale)
+		if page.Tags == nil {
+			page.Tags = []string{}
+		}
+		out = append(out, *page)
+	}
+	return out, nil
+}
+
+// CreatePage creates a page.
+func (c *Client) CreatePage(ctx context.Context, in CreatePageInput) (MutationResult, error) {
+	vars := map[string]any{
+		"title":       in.Title,
+		"content":     in.Content,
+		"description": in.Description,
+		"editor":      in.Editor,
+		"isPublished": in.IsPublished,
+		"isPrivate":   in.IsPrivate,
+		"locale":      in.Locale,
+		"tags":        nonNilStrings(in.Tags),
+		"path":        in.Path,
+	}
+
+	var data struct {
+		Pages *struct {
+			Create *struct {
+				ResponseResult *responseResult `json:"responseResult"`
+			} `json:"create"`
+		} `json:"pages"`
+	}
+	if err := c.do(ctx, "CreatePage", createPageMutation, vars, &data); err != nil {
+		return MutationResult{}, err
+	}
+	if data.Pages == nil || data.Pages.Create == nil {
+		return MutationResult{}, errors.New("wikijs CreatePage: missing create result")
+	}
+	return mutationResult(data.Pages.Create.ResponseResult)
+}
+
+// UpdatePage updates a page.
+func (c *Client) UpdatePage(ctx context.Context, in UpdatePageInput) (MutationResult, error) {
+	vars := map[string]any{
+		"id":          in.ID,
+		"title":       in.Title,
+		"content":     in.Content,
+		"description": in.Description,
+		"editor":      in.Editor,
+		"isPublished": in.IsPublished,
+		"isPrivate":   in.IsPrivate,
+		"locale":      in.Locale,
+		"tags":        nonNilStrings(in.Tags),
+		"path":        in.Path,
+	}
+
+	var data struct {
+		Pages *struct {
+			Update *struct {
+				ResponseResult *responseResult `json:"responseResult"`
+			} `json:"update"`
+		} `json:"pages"`
+	}
+	if err := c.do(ctx, "UpdatePage", updatePageMutation, vars, &data); err != nil {
+		return MutationResult{}, err
+	}
+	if data.Pages == nil || data.Pages.Update == nil {
+		return MutationResult{}, errors.New("wikijs UpdatePage: missing update result")
+	}
+	return mutationResult(data.Pages.Update.ResponseResult)
+}
+
+type responseResult struct {
+	Succeeded bool   `json:"succeeded"`
+	ErrorCode int    `json:"errorCode"`
+	Slug      string `json:"slug"`
+	Message   string `json:"message"`
+}
+
+func mutationResult(rr *responseResult) (MutationResult, error) {
+	if rr == nil {
+		return MutationResult{}, errors.New("wikijs mutation: missing responseResult")
+	}
+	return MutationResult{
+		Succeeded: rr.Succeeded,
+		Message:   rr.Message,
+		ErrorCode: rr.ErrorCode,
+		Slug:      rr.Slug,
+	}, nil
+}
+
+type graphqlRequest struct {
+	Query     string         `json:"query"`
+	Variables map[string]any `json:"variables,omitempty"`
+}
+
+type graphqlError struct {
+	Message string `json:"message"`
+}
+
+type graphqlResponse struct {
+	Data   json.RawMessage `json:"data"`
+	Errors []graphqlError  `json:"errors"`
+}
+
+// APIError describes a failed WikiJS request.
+type APIError struct {
+	Operation string
+	Status    int
+	Messages  []string
+	Body      string
+	Err       error
+}
+
+func (e *APIError) Error() string {
+	var b strings.Builder
+	b.WriteString("wikijs")
+	if e.Operation != "" {
+		b.WriteByte(' ')
+		b.WriteString(e.Operation)
+	}
+	b.WriteString(": ")
+	switch {
+	case len(e.Messages) > 0:
+		b.WriteString(strings.Join(e.Messages, "; "))
+	case e.Body != "":
+		b.WriteString(e.Body)
+	case e.Err != nil:
+		b.WriteString(e.Err.Error())
+	default:
+		b.WriteString("request failed")
+	}
+	if e.Status != 0 {
+		fmt.Fprintf(&b, " (HTTP %d)", e.Status)
+	}
+	return b.String()
+}
+
+func (e *APIError) Unwrap() error { return e.Err }
+
+func (c *Client) do(ctx context.Context, operation, query string, vars map[string]any, out any) error {
+	payload, err := json.Marshal(graphqlRequest{Query: query, Variables: vars})
+	if err != nil {
+		return fmt.Errorf("wikijs %s: encode request: %w", operation, err)
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(payload))
+	if err != nil {
+		return fmt.Errorf("wikijs %s: build request: %w", operation, err)
+	}
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Accept", "application/json")
+	req.Header.Set("Authorization", "Bearer "+c.apiKey)
+	req.Header.Set("User-Agent", c.userAgent)
+
+	start := time.Now()
+	resp, err := c.http.Do(req)
+	if err != nil {
+		return fmt.Errorf("wikijs %s: %w", operation, err)
+	}
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
+	if err != nil {
+		return fmt.Errorf("wikijs %s: read response: %w", operation, err)
+	}
+	if len(body) > maxResponseBytes {
+		return fmt.Errorf("wikijs %s: response exceeds %d bytes", operation, maxResponseBytes)
+	}
+
+	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
+		return &APIError{
+			Operation: operation,
+			Status:    resp.StatusCode,
+			Body:      truncate(strings.TrimSpace(string(body)), 512),
+		}
+	}
+
+	var envelope graphqlResponse
+	if err := json.Unmarshal(body, &envelope); err != nil {
+		return &APIError{Operation: operation, Err: fmt.Errorf("decode response: %w", err)}
+	}
+	if len(envelope.Errors) > 0 {
+		messages := make([]string, 0, len(envelope.Errors))
+		for _, e := range envelope.Errors {
+			messages = append(messages, e.Message)
+		}
+		return &APIError{Operation: operation, Messages: messages}
+	}
+	if len(envelope.Data) == 0 || string(envelope.Data) == "null" {
+		return &APIError{Operation: operation, Messages: []string{"response contains no data"}}
+	}
+	if err := json.Unmarshal(envelope.Data, out); err != nil {
+		return fmt.Errorf("wikijs %s: decode data: %w", operation, err)
+	}
+
+	c.logger.Debug("wikijs request",
+		"operation", operation,
+		"duration", time.Since(start),
+		"status", resp.StatusCode,
+	)
+	return nil
+}
+
+func (c *Client) decorate(page *Page) *Page {
+	page.URL = c.pageURL(page.Path, page.Locale)
+	if page.Tags == nil {
+		page.Tags = []Tag{}
+	}
+	return page
+}
+
+func (c *Client) pageURL(path, locale string) string {
+	p := strings.TrimLeft(path, "/")
+	if locale != "" && locale != "en" {
+		return c.baseURL + "/" + locale + "/" + p
+	}
+	return c.baseURL + "/" + p
+}
+
+func putString(vars map[string]any, key, value string) {
+	if value != "" {
+		vars[key] = value
+	}
+}
+
+func nonNilStrings(values []string) []string {
+	if values == nil {
+		return []string{}
+	}
+	return values
+}
+
+func truncate(s string, max int) string {
+	if len(s) <= max {
+		return s
+	}
+	return s[:max] + "..."
+}

+ 410 - 0
internal/wikijs/client_test.go

@@ -0,0 +1,410 @@
+package wikijs
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+func newTestClient(t *testing.T, handler http.HandlerFunc) *Client {
+	t.Helper()
+	srv := httptest.NewServer(handler)
+	t.Cleanup(srv.Close)
+
+	client, err := New(srv.URL, "test-key", WithUserAgent("wikijs-mcp/test"))
+	if err != nil {
+		t.Fatalf("New() error = %v", err)
+	}
+	return client
+}
+
+func decodeRequest(t *testing.T, r *http.Request) graphqlRequest {
+	t.Helper()
+	var req graphqlRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		t.Errorf("decode request: %v", err)
+	}
+	return req
+}
+
+func writeJSON(t *testing.T, w http.ResponseWriter, status int, body string) {
+	t.Helper()
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(status)
+	if _, err := w.Write([]byte(body)); err != nil {
+		t.Errorf("write response: %v", err)
+	}
+}
+
+func TestNewNormalizesURL(t *testing.T) {
+	tests := []struct {
+		base         string
+		wantBase     string
+		wantEndpoint string
+	}{
+		{base: "https://wiki.example.com", wantBase: "https://wiki.example.com", wantEndpoint: "https://wiki.example.com/graphql"},
+		{base: "https://wiki.example.com/", wantBase: "https://wiki.example.com", wantEndpoint: "https://wiki.example.com/graphql"},
+		{base: "https://wiki.example.com/graphql", wantBase: "https://wiki.example.com", wantEndpoint: "https://wiki.example.com/graphql"},
+		{base: "https://wiki.example.com/graphql/", wantBase: "https://wiki.example.com", wantEndpoint: "https://wiki.example.com/graphql"},
+		{base: "https://wiki.example.com/wiki", wantBase: "https://wiki.example.com/wiki", wantEndpoint: "https://wiki.example.com/wiki/graphql"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.base, func(t *testing.T) {
+			c, err := New(tt.base, "key")
+			if err != nil {
+				t.Fatalf("New() error = %v", err)
+			}
+			if c.baseURL != tt.wantBase {
+				t.Errorf("baseURL = %q, want %q", c.baseURL, tt.wantBase)
+			}
+			if c.endpoint != tt.wantEndpoint {
+				t.Errorf("endpoint = %q, want %q", c.endpoint, tt.wantEndpoint)
+			}
+		})
+	}
+}
+
+func TestNewRejectsInvalidURL(t *testing.T) {
+	for _, raw := range []string{"", "wiki.example.com", "ftp://wiki.example.com", "/relative", "https://wiki.example.com/?a=1", "https://wiki.example.com/#x"} {
+		if _, err := New(raw, "key"); err == nil {
+			t.Errorf("New(%q) error = nil, want error", raw)
+		}
+	}
+}
+
+func TestPageByID(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		if got, want := r.Header.Get("Authorization"), "Bearer test-key"; got != want {
+			t.Errorf("Authorization = %q, want %q", got, want)
+		}
+		if got, want := r.Header.Get("User-Agent"), "wikijs-mcp/test"; got != want {
+			t.Errorf("User-Agent = %q, want %q", got, want)
+		}
+		if got, want := r.Header.Get("Content-Type"), "application/json"; got != want {
+			t.Errorf("Content-Type = %q, want %q", got, want)
+		}
+		req := decodeRequest(t, r)
+		if !strings.Contains(req.Query, "single(id: $id)") {
+			t.Errorf("query does not contain single query: %s", req.Query)
+		}
+		if got := req.Variables["id"]; got != float64(42) {
+			t.Errorf("variables[id] = %v, want 42", got)
+		}
+		writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"single":{
+			"id":42,"path":"/dev/go","title":"Go","description":"desc",
+			"tags":[{"tag":"go","title":"Go"}],"content":"body",
+			"createdAt":"2025-01-01T00:00:00Z","updatedAt":"2025-01-02T00:00:00Z",
+			"locale":"de"}}}}`)
+	})
+
+	page, err := client.PageByID(context.Background(), 42)
+	if err != nil {
+		t.Fatalf("PageByID() error = %v", err)
+	}
+	if page == nil {
+		t.Fatal("PageByID() = nil, want page")
+	}
+	if page.ID != 42 || page.Title != "Go" {
+		t.Errorf("page = %+v", page)
+	}
+	if got, want := page.URL, client.baseURL+"/de/dev/go"; got != want {
+		t.Errorf("URL = %q, want %q", got, want)
+	}
+	if len(page.Tags) != 1 || page.Tags[0].Tag != "go" {
+		t.Errorf("Tags = %+v", page.Tags)
+	}
+}
+
+func TestPageByIDEnglishHasNoLocalePrefix(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"single":{
+			"id":1,"path":"/start","title":"Start","description":"",
+			"tags":null,"content":"","createdAt":"","updatedAt":"","locale":"en"}}}}`)
+	})
+
+	page, err := client.PageByID(context.Background(), 1)
+	if err != nil {
+		t.Fatalf("PageByID() error = %v", err)
+	}
+	if got, want := page.URL, client.baseURL+"/start"; got != want {
+		t.Errorf("URL = %q, want %q", got, want)
+	}
+	if page.Tags == nil {
+		t.Error("Tags = nil, want empty slice")
+	}
+}
+
+func TestPageByIDNotFound(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"single":null}}}`)
+	})
+
+	page, err := client.PageByID(context.Background(), 99)
+	if err != nil {
+		t.Fatalf("PageByID() error = %v", err)
+	}
+	if page != nil {
+		t.Errorf("PageByID() = %+v, want nil", page)
+	}
+}
+
+func TestPageByPath(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		req := decodeRequest(t, r)
+		if !strings.Contains(req.Query, "singleByPath") {
+			t.Errorf("query = %s", req.Query)
+		}
+		if got := req.Variables["path"]; got != "/start" {
+			t.Errorf("variables[path] = %v", got)
+		}
+		if got := req.Variables["locale"]; got != "de-DE" {
+			t.Errorf("variables[locale] = %v", got)
+		}
+		writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"singleByPath":{
+			"id":7,"path":"/start","title":"Start","description":"desc",
+			"tags":[],"content":"body","createdAt":"c","updatedAt":"u","locale":"de-DE"}}}}`)
+	})
+
+	page, err := client.PageByPath(context.Background(), "/start", "de-DE")
+	if err != nil {
+		t.Fatalf("PageByPath() error = %v", err)
+	}
+	if got, want := page.URL, client.baseURL+"/de-DE/start"; got != want {
+		t.Errorf("URL = %q, want %q", got, want)
+	}
+}
+
+func TestSearchPages(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		req := decodeRequest(t, r)
+		if got := req.Variables["query"]; got != "mcp" {
+			t.Errorf("variables[query] = %v", got)
+		}
+		if got := req.Variables["path"]; got != "/dev" {
+			t.Errorf("variables[path] = %v", got)
+		}
+		if _, ok := req.Variables["locale"]; ok {
+			t.Errorf("variables[locale] = %v, want omitted", req.Variables["locale"])
+		}
+		writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"search":{
+			"results":[
+				{"title":"A","description":"a","path":"/a","locale":"en"},
+				null,
+				{"title":"B","description":"b","path":"/b","locale":"de"}
+			],
+			"suggestions":["s1"],"totalHits":3}}}}`)
+	})
+
+	res, err := client.SearchPages(context.Background(), "mcp", "/dev", "")
+	if err != nil {
+		t.Fatalf("SearchPages() error = %v", err)
+	}
+	if got, want := res.TotalHits, 3; got != want {
+		t.Errorf("TotalHits = %d, want %d", got, want)
+	}
+	if len(res.Results) != 2 {
+		t.Fatalf("Results = %d, want 2", len(res.Results))
+	}
+	if got, want := res.Results[0].URL, client.baseURL+"/a"; got != want {
+		t.Errorf("URL = %q, want %q", got, want)
+	}
+	if got, want := res.Results[1].URL, client.baseURL+"/de/b"; got != want {
+		t.Errorf("URL = %q, want %q", got, want)
+	}
+	if len(res.Suggestions) != 1 || res.Suggestions[0] != "s1" {
+		t.Errorf("Suggestions = %v", res.Suggestions)
+	}
+}
+
+func TestSearchPagesEmpty(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"search":null}}}`)
+	})
+
+	res, err := client.SearchPages(context.Background(), "nothing", "", "")
+	if err != nil {
+		t.Fatalf("SearchPages() error = %v", err)
+	}
+	if res.Results == nil || res.Suggestions == nil {
+		t.Errorf("empty slices must not be nil: %+v", res)
+	}
+	if res.TotalHits != 0 {
+		t.Errorf("TotalHits = %d, want 0", res.TotalHits)
+	}
+}
+
+func TestListPages(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		req := decodeRequest(t, r)
+		if got := req.Variables["limit"]; got != float64(10) {
+			t.Errorf("variables[limit] = %v", got)
+		}
+		if got := req.Variables["locale"]; got != "de" {
+			t.Errorf("variables[locale] = %v", got)
+		}
+		tags, ok := req.Variables["tags"].([]any)
+		if !ok || len(tags) != 2 || tags[0] != "go" {
+			t.Errorf("variables[tags] = %v", req.Variables["tags"])
+		}
+		writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"list":[
+			{"id":1,"path":"/a","locale":"de","title":"A","description":"a",
+			 "createdAt":"c","updatedAt":"u","tags":["go"]},
+			null,
+			{"id":2,"path":"/b","locale":"en","title":"B","description":"b",
+			 "createdAt":"c","updatedAt":"u","tags":null}
+		]}}}`)
+	})
+
+	limit := 10
+	pages, err := client.ListPages(context.Background(), ListOptions{
+		Limit:  &limit,
+		Locale: "de",
+		Tags:   []string{"go", "mcp"},
+	})
+	if err != nil {
+		t.Fatalf("ListPages() error = %v", err)
+	}
+	if len(pages) != 2 {
+		t.Fatalf("ListPages() returned %d pages, want 2", len(pages))
+	}
+	if got, want := pages[0].URL, client.baseURL+"/de/a"; got != want {
+		t.Errorf("URL = %q, want %q", got, want)
+	}
+	if pages[1].Tags == nil {
+		t.Error("Tags = nil, want empty slice")
+	}
+}
+
+func TestListPagesOmitsEmptyFilters(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		req := decodeRequest(t, r)
+		for _, key := range []string{"limit", "locale", "tags"} {
+			if _, ok := req.Variables[key]; ok {
+				t.Errorf("variables[%s] present, want omitted", key)
+			}
+		}
+		writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"list":[]}}}`)
+	})
+
+	if _, err := client.ListPages(context.Background(), ListOptions{}); err != nil {
+		t.Fatalf("ListPages() error = %v", err)
+	}
+}
+
+func TestCreatePage(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		req := decodeRequest(t, r)
+		if !strings.Contains(req.Query, "mutation CreatePage") {
+			t.Errorf("query = %s", req.Query)
+		}
+		if got := req.Variables["tags"].([]any); len(got) != 1 || got[0] != "go" {
+			t.Errorf("variables[tags] = %v", req.Variables["tags"])
+		}
+		if got := req.Variables["isPublished"]; got != true {
+			t.Errorf("variables[isPublished] = %v", got)
+		}
+		writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"create":{"responseResult":{
+			"succeeded":true,"errorCode":0,"slug":"new-page","message":"Page created"}}}}}`)
+	})
+
+	res, err := client.CreatePage(context.Background(), CreatePageInput{
+		Title:       "New",
+		Content:     "body",
+		Description: "desc",
+		Path:        "new-page",
+		Editor:      "markdown",
+		IsPublished: true,
+		Locale:      "en",
+		Tags:        []string{"go"},
+	})
+	if err != nil {
+		t.Fatalf("CreatePage() error = %v", err)
+	}
+	if !res.Succeeded || res.Message != "Page created" || res.Slug != "new-page" {
+		t.Errorf("result = %+v", res)
+	}
+}
+
+func TestUpdatePageFailureIsReturned(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"update":{"responseResult":{
+			"succeeded":false,"errorCode":6001,"slug":"","message":"Page not found"}}}}}`)
+	})
+
+	res, err := client.UpdatePage(context.Background(), UpdatePageInput{ID: 1})
+	if err != nil {
+		t.Fatalf("UpdatePage() error = %v", err)
+	}
+	if res.Succeeded {
+		t.Error("Succeeded = true, want false")
+	}
+	if res.ErrorCode != 6001 || res.Message != "Page not found" {
+		t.Errorf("result = %+v", res)
+	}
+}
+
+func TestGraphQLErrors(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		writeJSON(t, w, http.StatusOK, `{"errors":[{"message":"Unauthorized"},{"message":"Forbidden"}]}`)
+	})
+
+	_, err := client.PageByID(context.Background(), 1)
+	if err == nil {
+		t.Fatal("PageByID() error = nil, want error")
+	}
+	var apiErr *APIError
+	if !errors.As(err, &apiErr) {
+		t.Fatalf("error type = %T, want *APIError", err)
+	}
+	if got := apiErr.Error(); !strings.Contains(got, "Unauthorized") || !strings.Contains(got, "Forbidden") {
+		t.Errorf("error = %q", got)
+	}
+}
+
+func TestHTTPError(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		http.Error(w, "server exploded", http.StatusInternalServerError)
+	})
+
+	_, err := client.PageByID(context.Background(), 1)
+	if err == nil {
+		t.Fatal("PageByID() error = nil, want error")
+	}
+	var apiErr *APIError
+	if !errors.As(err, &apiErr) {
+		t.Fatalf("error type = %T, want *APIError", err)
+	}
+	if apiErr.Status != http.StatusInternalServerError {
+		t.Errorf("Status = %d, want 500", apiErr.Status)
+	}
+	if !strings.Contains(err.Error(), "server exploded") {
+		t.Errorf("error = %q", err)
+	}
+}
+
+func TestMissingData(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		writeJSON(t, w, http.StatusOK, `{"data":null}`)
+	})
+
+	_, err := client.PageByID(context.Background(), 1)
+	if err == nil {
+		t.Fatal("PageByID() error = nil, want error")
+	}
+}
+
+func TestContextCancellation(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		t.Error("handler must not be called")
+	})
+	ctx, cancel := context.WithCancel(context.Background())
+	cancel()
+
+	if _, err := client.PageByID(ctx, 1); err == nil {
+		t.Fatal("PageByID() error = nil, want context error")
+	}
+}

+ 137 - 0
internal/wikijs/queries.go

@@ -0,0 +1,137 @@
+package wikijs
+
+const searchPagesQuery = `query SearchPages($query: String!, $path: String, $locale: String) {
+  pages {
+    search(query: $query, path: $path, locale: $locale) {
+      results {
+        title
+        description
+        path
+        locale
+      }
+      suggestions
+      totalHits
+    }
+  }
+}`
+
+const pageByIDQuery = `query GetPageById($id: Int!) {
+  pages {
+    single(id: $id) {
+      id
+      path
+      title
+      description
+      tags {
+        tag
+        title
+      }
+      content
+      createdAt
+      updatedAt
+      locale
+    }
+  }
+}`
+
+const pageByPathQuery = `query GetPageByPath($path: String!, $locale: String!) {
+  pages {
+    singleByPath(path: $path, locale: $locale) {
+      id
+      path
+      title
+      description
+      content
+      createdAt
+      updatedAt
+      locale
+      tags {
+        tag
+        title
+      }
+    }
+  }
+}`
+
+const listPagesQuery = `query GetAllPages($limit: Int, $locale: String, $tags: [String!]) {
+  pages {
+    list(limit: $limit, locale: $locale, tags: $tags) {
+      id
+      path
+      locale
+      title
+      description
+      createdAt
+      updatedAt
+      tags
+    }
+  }
+}`
+
+const createPageMutation = `mutation CreatePage(
+  $title: String!
+  $content: String!
+  $description: String!
+  $editor: String!
+  $isPublished: Boolean!
+  $isPrivate: Boolean!
+  $locale: String!
+  $tags: [String]!
+  $path: String!
+) {
+  pages {
+    create(
+      title: $title
+      content: $content
+      description: $description
+      editor: $editor
+      isPublished: $isPublished
+      isPrivate: $isPrivate
+      locale: $locale
+      tags: $tags
+      path: $path
+    ) {
+      responseResult {
+        succeeded
+        errorCode
+        slug
+        message
+      }
+    }
+  }
+}`
+
+const updatePageMutation = `mutation UpdatePage(
+  $id: Int!
+  $title: String!
+  $content: String!
+  $description: String!
+  $editor: String!
+  $isPublished: Boolean!
+  $isPrivate: Boolean!
+  $locale: String!
+  $tags: [String]!
+  $path: String!
+) {
+  pages {
+    update(
+      id: $id
+      title: $title
+      content: $content
+      description: $description
+      editor: $editor
+      isPublished: $isPublished
+      isPrivate: $isPrivate
+      locale: $locale
+      tags: $tags
+      path: $path
+    ) {
+      responseResult {
+        succeeded
+        errorCode
+        slug
+        message
+      }
+    }
+  }
+}`

+ 94 - 0
internal/wikijs/types.go

@@ -0,0 +1,94 @@
+// Package wikijs is a small typed client for the WikiJS GraphQL API.
+package wikijs
+
+// Tag is a tag attached to a single page.
+type Tag struct {
+	Tag   string `json:"tag"`
+	Title string `json:"title"`
+}
+
+// Page is a full page as returned by the single and singleByPath queries.
+type Page struct {
+	ID          int    `json:"id"`
+	Path        string `json:"path"`
+	Title       string `json:"title"`
+	Description string `json:"description"`
+	Tags        []Tag  `json:"tags"`
+	Content     string `json:"content"`
+	CreatedAt   string `json:"createdAt"`
+	UpdatedAt   string `json:"updatedAt"`
+	Locale      string `json:"locale"`
+	URL         string `json:"url"`
+}
+
+// PageListItem is a page entry as returned by the list query.
+type PageListItem struct {
+	ID          int      `json:"id"`
+	Path        string   `json:"path"`
+	Title       string   `json:"title"`
+	Description string   `json:"description"`
+	Tags        []string `json:"tags"`
+	CreatedAt   string   `json:"createdAt"`
+	UpdatedAt   string   `json:"updatedAt"`
+	Locale      string   `json:"locale"`
+	URL         string   `json:"url"`
+}
+
+// SearchHit is a single search result.
+type SearchHit struct {
+	Title       string `json:"title"`
+	Description string `json:"description"`
+	Path        string `json:"path"`
+	Locale      string `json:"locale"`
+	URL         string `json:"url"`
+}
+
+// SearchResults is the result of a page search.
+type SearchResults struct {
+	Results     []SearchHit `json:"results"`
+	Suggestions []string    `json:"suggestions"`
+	TotalHits   int         `json:"totalHits"`
+}
+
+// ListOptions filters the pages returned by [Client.ListPages]. Empty fields are
+// omitted from the GraphQL request.
+type ListOptions struct {
+	Limit  *int
+	Locale string
+	Tags   []string
+}
+
+// CreatePageInput describes a new page.
+type CreatePageInput struct {
+	Title       string
+	Content     string
+	Description string
+	Path        string
+	Editor      string
+	IsPublished bool
+	IsPrivate   bool
+	Locale      string
+	Tags        []string
+}
+
+// UpdatePageInput describes an existing page and its new content.
+type UpdatePageInput struct {
+	ID          int
+	Title       string
+	Content     string
+	Description string
+	Path        string
+	Editor      string
+	IsPublished bool
+	IsPrivate   bool
+	Locale      string
+	Tags        []string
+}
+
+// MutationResult is the normalized WikiJS responseResult of a mutation.
+type MutationResult struct {
+	Succeeded bool   `json:"succeeded"`
+	Message   string `json:"message"`
+	ErrorCode int    `json:"-"`
+	Slug      string `json:"-"`
+}