# Set up the MCP read server Serve an instance's wiki read-only over MCP, so that a consumer that is not a terminal on this machine - another agent, a chat client, a service - can ask it the same questions and get the same answers. This page covers what you set up around the server: the client, the authentication in front of it, the optional `submit` path, and how to check that it works. Installing, starting and syncing the server itself is [instructions/mcp-read-server.md](../../instructions/mcp-read-server.md), which an agent follows and which is readable for you as well. ## Contents - [What the server is, and what it is not](#what-the-server-is-and-what-it-is-not) - [What you prepare](#what-you-prepare) - [Wiring a client to it](#wiring-a-client-to-it) - [Running it as a service](#running-it-as-a-service) - [Authentication in front of it](#authentication-in-front-of-it) - [Keeping the served tree current](#keeping-the-served-tree-current) - [Optional: the submit path](#optional-the-submit-path) - [Checking that it works](#checking-that-it-works) - [Troubleshooting](#troubleshooting) - [What is deliberately not here](#what-is-deliberately-not-here) ## What the server is, and what it is not The server is a second consumer of the same core as the CLI, not a second program. Both call the same functions, and a golden test in the origin repository holds their output together. What `tools/wikitool search --json` returns, the MCP tool `search` returns too, together with the commit the answer was computed from. It is not a write path into `kb/`. No tool creates, changes or publishes a page - not because a list is filtered, but because the server imports nothing under `tools/chemenu/commands/`. Those functions cannot be reached from its process. The one exception is opt-in: a sixth tool, `submit`, which writes only into a quarantine no other command reads. A human decides about everything that leaves it ([below](#optional-the-submit-path)). | Tool | What it answers | |---|---| | `search` | Pages in `kb/` by text, by frontmatter (`!sources`, `tags~k8s`), or both | | `types` | Which page types this wiki knows | | `describe_type` | A type's full contract: fields, required values, enums | | `lint` | Structural findings: broken wikilinks, orphans, catalog drift, schema gaps | | `status` | A snapshot: page count, distribution over collections, finding counts | | `submit` *(optional)* | Hands a document to the review queue - into a quarantine, never into `kb/` | ## What you prepare - A working instance ([INSTALL.md](../../INSTALL.md)), including `tools/.venv` and ripgrep. - For a deployment, a reverse proxy that can authenticate requests before they reach the server ([below](#authentication-in-front-of-it)). Then ask the agent: ```text Set up the MCP read server for this instance ``` It installs the server's optional dependency, which is deliberately not in `tools/requirements.txt`: an instance that only uses the CLI should not have to install pydantic, starlette, uvicorn and cryptography. It also checks that tracing cannot write into the served tree. The server refuses to start if it could: ```text ERROR Telemetry is on and would write into the served checkout (...). Set WIKI_TRACE=0, or point WIKI_TRACE_DIR outside the corpus. ``` That refusal is expected, not an installation fault. Telemetry is off by default in an instance installed from a release and on in a clone of the origin repository ([configuration](../reference/configuration.md#telemetry)). The server consults the same policy `wikitool doctor` does. Setting `WIKI_TRACE=0` explicitly, as every example here does, is correct either way and makes the check moot. ## Wiring a client to it Locally, over `stdio`, the client starts the server itself: one process per consumer, no network. The configuration follows the usual MCP client shape, with absolute paths, because the client inherits no working directory: ```json { "mcpServers": { "chemenu": { "command": "/path/to/instance/tools/.venv/bin/python", "args": ["-m", "chemenu.mcp"], "cwd": "/path/to/instance/tools", "env": { "WIKI_TRACE": "0", "CHEMENU_ROOT": "/path/to/instance" } } } } ``` `CHEMENU_ROOT` says **which** wiki is served. Without it, the server takes the checkout its own package sits in. That is enough for a single instance, but with several corpora it is safer to always set it. The process speaks MCP over stdin and stdout and prints nothing of its own, which is correct. ## Running it as a service Deployed, the server speaks `streamable-http`, the only transport a reverse proxy can sit in front of. The command a service unit or container runs looks like this: ```bash WIKI_TRACE=0 CHEMENU_ROOT=/srv/chemenu \ tools/.venv/bin/python -m chemenu.mcp \ --transport streamable-http --host 0.0.0.0 --port 8000 ``` The endpoint is then `http://:8000/mcp`. `--host 0.0.0.0` is deliberately not the default: without it the server binds to loopback, which is right locally and wrong in a container, where the proxy has to reach it. If you set it, make sure something stands in front. `sse` is not offered. It is the superseded remote transport, and building on it now would only postpone the switch. ## Authentication in front of it **The server does not authenticate on its own, by design.** A request that is not properly authenticated should never reach the Python process at all. The authentication is a Traefik ForwardAuth middleware: > **** In short, it checks `Authorization: Bearer ` against SHA-256 hashes of allowed tokens. It answers `200` for a valid token and `401` for a missing or wrong one, and always leaves `GET /healthz` open. Plaintext tokens appear neither in the configuration nor in the log, only hashes and a short fingerprint. It is configured with `AUTH_PROXY_TOKEN_HASHES_DIR` (a directory with one file per token hash, which suits a Kubernetes secret volume) or `AUTH_PROXY_TOKEN_HASHES` (a comma-separated list). The README there has the details, reference manifests and a test script. To create a token hash: ```bash echo -n "my-token" | sha256sum | awk '{print $1}' ``` **Rate limiting belongs in the same place,** in front of the process and beside the authentication. It does not belong in the Iteration Budget Gate. That gate stops an *agent session* from iterating over the wiki's state unnoticed, which is why retrieval is exempt from it. Using it as a rate limiter would water it down. ## Keeping the served tree current The server reads the working tree, and a stale checkout answers confidently and wrongly. So **every answer carries the commit** it was computed from: ```json { "commit": "<40-character SHA>", "as_of": "", "count": 3, "...": "..." } ``` A timer beside the server keeps the tree current by polling: a fetch, then a hard reset to the remote branch. The two commands, and why the hard reset is load-bearing, are in the instruction's sync step. Polling rather than a webhook, because it needs no inbound endpoint and no signature check, which is a smaller attack surface than the thing a webhook would optimise. **Never add `git clean` to that timer.** The reset leaves every gitignored path alone, which is what keeps the `submit` quarantine and any traces intact. A clean would silently delete a submission nobody has reviewed yet. ## Optional: the submit path Without this, `submit` does not exist as a tool - not unused, but not registered. A file `.wikitool-upload.json` in the served corpus turns it on. Its fields, defaults and what a malformed file does are in the [configuration reference](../reference/configuration.md#mcp-submissions); a broken file never means "no limits", it stops the server from starting. **With `submit` on, the middleware has a second duty.** It has to set the identity header itself, on every authenticated request, and discard any copy the client sent. The server trusts that header as the value for `submitter`. A header the client could set itself would be a claim, not an identity. If `identity_header` names a header the middleware does not set, every submission is refused for lack of an identity. Submitted files land in `mcp-upload//`, which is gitignored and read by no other command. From there a human promotes or rejects them, in the same checkout as the server rather than in its process. That review is part of [ingesting a source](ingest-a-source.md#a-document-submitted-from-outside). ## Checking that it works The quickest test without a client starts the server over stdio, lists its tools and asks one question: ```bash cd tools && WIKI_TRACE=0 .venv/bin/python - <<'EOF' import asyncio, os from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def main(): params = StdioServerParameters( command=".venv/bin/python", args=["-m", "chemenu.mcp"], env={"WIKI_TRACE": "0", "PATH": os.environ["PATH"]}, ) async with stdio_client(params) as (r, w): async with ClientSession(r, w) as s: await s.initialize() print("Tools:", [t.name for t in (await s.list_tools()).tools]) out = await s.call_tool("status", {}) d = getattr(out, "structuredContent", None) or out.structured_content print("Pages:", d["pages"], "| Commit:", d["commit"]) asyncio.run(main()) EOF ``` Expected output, roughly: ```text Tools: ['search', 'types', 'describe_type', 'lint', 'status'] Pages: 176 | Commit: 576df2cdddc96614a7e6641e562022d52112d411 ``` Then cross-check against the CLI. Both have to give the same answer: ```bash tools/wikitool search "" --json ``` ## Troubleshooting **`ERROR Telemetry is on and would write into the served checkout`**: expected behaviour, not an installation fault. Set `WIKI_TRACE=0`, or point `WIKI_TRACE_DIR` outside the corpus. **`ModuleNotFoundError: No module named 'mcp'`**: the optional dependency is not installed, or the client starts a different Python than the instance's. Give the client the absolute path to `tools/.venv/bin/python`. **`"commit": null` in every answer**: the served tree has uncommitted changes. Either the sync is not running, or something writes into the corpus that has no business there. The five read tools never write. `submit`, when it is on, writes only to `mcp-upload/`, which is gitignored and so is never a cause of `"commit": null` either. **`commit` names an old revision**: the sync timer is not running. **The server answers differently from `wikitool`**: that is a defect, not a configuration difference, since both go through the same functions. First check that both point at the same root. `CHEMENU_ROOT` is easy to set for one and not the other. **Not reachable from outside**: without `--host 0.0.0.0` the server binds to loopback. If it is reachable but every call gets `401`, the middleware is working correctly and the token is wrong. ## What is deliberately not here Deployment specifics - the cluster, ingress hosts, the secret store, the GitOps source - are private infrastructure, and this repository is public. There is no container image for the server yet. Until there is, the path above - the venv, `python -m chemenu.mcp`, a proxy in front - is the complete one. With `submit` on, `mcp-upload/` has to survive restarts the same way the rest of the checkout does. Otherwise a submitted file that nobody has reviewed yet loses its quarantine.