Files
chemenu/docs/how-to/set-up-the-mcp-server.md
T
torbenandClaude Code cceb9a7bfe
CI / verify (push) Successful in 8m41s
CI / pwsh (push) Successful in 2m44s
Release / release (push) Successful in 35s
Release / wiki-mirror (push) Skipped
intake: several configured intake channels (mcp, folder, command) all end in quarantine/ (was mcp-upload/), and upload collect pulls the synced-folder and command channels into it (#213); 10.0.0-beta.11
Files changed:
- .gitignore
- CHANGES.md
- VERSION
- docs/explanation/architecture.md
- docs/explanation/pipeline-rationale.md
- docs/how-to/README.md
- docs/how-to/back-up-to-a-synced-folder.md
- docs/how-to/collect-from-a-synced-folder.md
- docs/how-to/ingest-a-source.md
- docs/how-to/set-up-the-mcp-server.md
- docs/reference/commands/README.md
- docs/reference/commands/git.md
- docs/reference/commands/instance-health.md
- docs/reference/commands/raw-material-and-uploads.md
- docs/reference/configuration.md
- instructions/gates.md
- instructions/ingest-queue.md
- instructions/mcp-read-server.md
- instructions/migrations/10.0.0-settings-tiers.md
- instructions/wiki-ingest/SKILL.md
- raw/CONTRACT.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/cli_contract.py
- tools/chemenu/collect.py
- tools/chemenu/commands/backup_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/raw/pending.py
- tools/chemenu/commands/upload_cmd.py
- tools/chemenu/config.py
- tools/chemenu/config_shapes.py
- tools/chemenu/legacy_settings.py
- tools/chemenu/mcp/server.py
- tools/chemenu/reference_doc.py
- tools/chemenu/tests/test_backup.py
- tools/chemenu/tests/test_collect.py
- tools/chemenu/tests/test_config_shapes.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_legacy_settings.py
- tools/chemenu/tests/test_mcp_server.py
- tools/chemenu/tests/test_upload.py
- tools/chemenu/tests/test_upload_cmd.py
- tools/chemenu/upload.py

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
2026-10-12 22:04:18 +02:00

11 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

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/.venv and 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:

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:

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 channel with the handler mcp in the intake section of the served checkout's .wikitool-local.json turns it on. Its fields, defaults and what a malformed section does are in the configuration reference; a broken section 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 the channel's identity_header names a header the middleware does not set, every submission is refused for lack of an identity.

Submitted files land in quarantine/<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 quarantine/, 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, quarantine/ 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.