Files changed: - .gitea/workflows/ci.yml - .gitignore - .vibe/hooks.toml - AGENTS.md - CHANGES.md - EVALS.md - INSTALL-MCP.md - INSTALL.md - README.md - VERSION - docs/dev/development.md - docs/explanation/README.md - docs/explanation/architecture.md - docs/explanation/language-boundaries.md - docs/explanation/telemetry-and-evaluation.md - docs/how-to/README.md - docs/how-to/browse-with-obsidian.md - docs/how-to/capture-repository-docs.md - docs/how-to/export-guidelines.md - docs/how-to/ingest-a-source.md - docs/how-to/maintain-the-wiki.md - docs/how-to/query-the-wiki.md - docs/how-to/run-the-weekly-review.md - docs/how-to/set-up-the-mcp-server.md - docs/how-to/trace-and-score-sessions.md - docs/how-to/troubleshoot.md - docs/how-to/update-an-instance.md - docs/reference/README.md - docs/reference/commands/distribution-and-versioning.md - docs/reference/commands/instance-health.md - docs/reference/commands/raw-material-and-uploads.md - docs/reference/commands/telemetry.md - docs/reference/commands/types-instructions-and-docs.md - docs/reference/configuration.md - instructions/dev/dev-setup.md - instructions/dev/doc-pull-through.md - instructions/dev/stack-build/SKILL.md - instructions/dev/testing-conventions.md - instructions/dev/version-parts.md - instructions/setup-instance.md - instructions/upgrade-instance.md - reports/CONTRACT.md - tools/.coveragerc - tools/CONTRACT.md - tools/README.md - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/eval_cmd.py - tools/chemenu/commands/git_publish.py - tools/chemenu/commands/upload_cmd.py - tools/chemenu/commands/version_cmd.py - tools/chemenu/evals/__init__.py - tools/chemenu/evals/scorecard.py - tools/chemenu/evals/trajectory.py - tools/chemenu/telemetry/policy.py - tools/chemenu/tests/test_dist_cmd.py - tools/chemenu/tests/test_git_publish.py - tools/chemenu/tests/test_instructions_shell.py - tools/chemenu/tests/test_pipeline_l0.py - tools/chemenu/toc.py Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
12 KiB
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, which an agent follows
and which is readable for you as well.
Contents
- What the server is, and what it is not
- What you prepare
- Wiring a client to it
- Running it as a service
- Authentication in front of it
- Keeping the served tree current
- Optional: the submit path
- Checking that it works
- Troubleshooting
- 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).
| 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), including
tools/.venvand ripgrep. - For a deployment, a reverse proxy that can authenticate requests before they reach the server (below).
Then ask the agent:
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:
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). 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:
{
"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:
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:
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:
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:
{ "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:
{
"schema": 1,
"identity_header": "X-Forwarded-User",
"max_bytes": 10485760,
"allowed_extensions": [".md", ".txt", ".pdf", ".html", ".csv", ".json", ".png", ".jpg"],
"quota": { "submissions_per_day": 20, "bytes_per_day": 52428800 }
}
Every field is required, and none has a built-in default except identity_header. A malformed file
stops the server from starting. It never means "no limits": the failure deliberately goes the safe
way. Size and quota are your deployment's decision, not defaults worth copying blindly.
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.
Checking that it works
The quickest test without a client starts the server over stdio, lists its tools and asks one question:
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:
Tools: ['search', 'types', 'describe_type', 'lint', 'status']
Pages: 176 | Commit: 576df2cdddc96614a7e6641e562022d52112d411
Then cross-check against the CLI. Both have to give the same answer:
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.