The telemetry, remotes, upload and tasks loaders take field names, defaults, coercions and their Expected: text from the record and accept exactly what they did before. doctor WARNs on a telemetry file the policy silently reads differently. configuration.md carries one generated region per file, also checked in an instance. docs reference --apply now refreshes several regions in one page in sequence instead of keeping only the last. Files changed: - CHANGES.md - VERSION - docs/how-to/set-up-the-mcp-server.md - docs/reference/README.md - docs/reference/commands/instance-health.md - docs/reference/commands/types-instructions-and-docs.md - docs/reference/configuration.md - instructions/dev/doc-pull-through.md - instructions/setup-instance.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/git_publish.py - tools/chemenu/config.py - tools/chemenu/config_shapes.py - tools/chemenu/reference_doc.py - tools/chemenu/tasks/caldav.py - tools/chemenu/tasks/config.py - tools/chemenu/tasks/superproductivity.py - tools/chemenu/telemetry/policy.py - tools/chemenu/tests/test_config_shapes.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_reference_doc.py - tools/chemenu/upload.py Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
252 lines
11 KiB
Markdown
252 lines
11 KiB
Markdown
# 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.
|
|
|
|
<!-- wikitool:toc -->
|
|
## 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)
|
|
<!-- /wikitool:toc -->
|
|
|
|
## 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://<host>: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:
|
|
|
|
> **<https://gitea.nehmer.net/torben/gitea-mcp-forward-auth>**
|
|
|
|
In short, it checks `Authorization: Bearer <token>` 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": "<ISO-8601, UTC>", "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/<id>/`, 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 "<term>" --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.
|