f7597b209c
Files changed: - kb/concepts/INDEX.md - kb/concepts/Iteration and Cost Limits.md - kb/concepts/MCP-Leseserver.md - kb/concepts/Mass-Update Gate.md - kb/concepts/Publish-Remote Gate.md - kb/entities/INDEX.md - kb/entities/projects/Chemenu.md - kb/entities/tools/wikitool.md - kb/index.md - kb/log.md - kb/provenance.md - kb/sources/INDEX.md - kb/sources/Source - MCP Read Server Implementation Session 2026-09-02.md - raw/notes/Conversation Transcript - MCP Read Server Implementation Session 2026-09-02.md
246 lines
17 KiB
Markdown
246 lines
17 KiB
Markdown
# Conversation Transcript - MCP Read Server Implementation Session
|
|
|
|
> Source: Claude Code session (`claude-opus-5`), chemenu workspace
|
|
> Collected: 2026-09-02
|
|
> Participant: Torben
|
|
> Fidelity: **faithful summary transcript, not a verbatim log.** Torben's instructions are
|
|
> quoted verbatim; the assistant's design reasoning, code, and file contents are condensed to
|
|
> what a later reader needs to find the artifact - the artifacts themselves are the code, not
|
|
> this transcript. Command outputs shown (test counts, timings, the Mass-Update Gate's printed
|
|
> file list, gate refusal messages) are real, copied from the session. No credentials appeared.
|
|
> No subagents were used - all work in this transcript is first-hand.
|
|
> One of two transcripts cut from this session; the other covers fixing `kb/entities/tools/qmd.md`,
|
|
> a documentation defect noted but not fixed during this one.
|
|
|
|
Covers implementing the four-issue sequence held by #36 (the master issue for "path to the MCP
|
|
read server"): #34 (arm the Publish-Remote Gate), #33 (harden the read path), #31 (root
|
|
resolution + library boundary), #19 (the MCP server itself), plus the human-facing
|
|
`INSTALL-MCP.md` and filing #37 (the container-image follow-up) that #19's closure surfaced as
|
|
still missing. Four version bumps in sequence: 2.2.3, 2.2.4, 2.3.0, 2.4.0, then 2.4.1 for the
|
|
documentation follow-up. All five touched issues (#34, #33, #31, #19, #36) got closing comments;
|
|
#36 was left open pending #37.
|
|
|
|
---
|
|
|
|
## Turn 1 - `/stack-dev implementiere #36`
|
|
|
|
The `stack-dev` skill loaded, switching the session into tool-development rules (ordinary
|
|
software-engineering judgment on `tools/`, `types/`, `instructions/` - no source-binding, no
|
|
`raw/` citation needed for code).
|
|
|
|
Read issue #36 (the master issue) via `gitea-mcp`. It holds no implementation itself - a
|
|
sequence table (#34 → #33 → #31 → #19), a table of platform/auth/transport decisions already
|
|
made and closed to debate, a measured baseline (593 ms per `wikitool search`, broken down:
|
|
262 ms module import, 274 ms corpus parse, 7 ms ripgrep), ten verified findings backing the
|
|
design, and an explicit "not in scope" list (Go, deployment details, a write path, #15's
|
|
research capability). Read #34, #33, #31, #19 in full including comments to get the acceptance
|
|
criteria and the reasoning behind each.
|
|
|
|
Worked the sequence in order, starting a session-scoped `WIKITOOL_SESSION_ID`.
|
|
|
|
**#34 - Publish-Remote Gate.** `.wikitool-remotes.json` did not exist in this checkout; absence
|
|
means unrestricted per `git_publish.read_allowed_push_urls()`. Created it with `origin`'s exact
|
|
push URL as the sole allowed target. Counter-checked by adding a second git remote to a made-up
|
|
URL and running `publish --remote gatecheck`: refused with **exit 42**, working tree untouched,
|
|
before any fetch. Remote removed afterward.
|
|
|
|
`doctor`'s `check_publish_remotes()` reported only whether the *file* existed, not whether the
|
|
gate was actually armed. Rewrote its three outcome messages to lead with `Gate armed:` /
|
|
`Gate not armed:` explicitly - the single-remote-no-file case stays `OK` (nothing to protect,
|
|
and a FAIL would make the file mandatory through the back door) but now says outright that every
|
|
push target passes. The check had **no tests**; added three, one per state.
|
|
|
|
Bumped `--patch` → **2.2.3**.
|
|
|
|
**#33 - Harden the read path.** Six findings, each addressed with a regression:
|
|
|
|
1. *ReDoS.* `search/ripgrep.py:_contains` passed `query.regex` to `re.search`. Deleted the
|
|
branch entirely rather than bounding it - `rg` already applies the pattern with a linear
|
|
engine before this function runs, so nothing is lost except the extra title/summary ranking
|
|
boost for a non-literal pattern (and summary/H1 lines are themselves counted by `rg`).
|
|
Regression test asserts both the timing (`< 0.5s` against `(\w+\s?)+$`) and the return value,
|
|
because a bound alone would pass with a merely-faster engine.
|
|
2. *No subprocess timeout.* Added `RIPGREP_TIMEOUT_SECONDS = 30.0`; `TimeoutExpired` translates
|
|
to the existing `RipgrepFailed` path.
|
|
3. *YAML aliases.* Frontmatter has no legitimate use for anchors/aliases, so they are refused
|
|
outright rather than budgeted, checked on the streaming *event* parse (`yaml.parse`) so the
|
|
check itself never triggers the expansion it is checking for - `*` is a necessary character
|
|
in any alias node, so its absence proves absence with zero parse cost. Constructed a
|
|
reproduction: 267 bytes of nested aliases compose into 672,603 nodes on traversal at
|
|
constant (0.2 ms) parse time, growth 9^n with nesting depth. Added a 64 KiB frontmatter size
|
|
limit and a `RecursionError` catch (PyYAML composes recursively; deep nesting is not a
|
|
`YAMLError`).
|
|
4. *`CSafeLoader` unused.* Switched to it with a `SafeLoader` fallback. Measured over this
|
|
corpus (176 pages, best of 5): **265 ms → 54 ms**.
|
|
5. *Silent frontmatter loss.* Decided: the read path now *reports* rather than swallows.
|
|
Unparseable YAML still degrades to `{}` for bulk operations, but the reason travels with it
|
|
(`Page.frontmatter_error`, set by a single parser shared between `read_page()` and
|
|
`frontmatter_error()` - previously two separate `safe_load` call sites that could describe
|
|
the same broken file differently). `search --json` now always carries an `unreadable` list
|
|
of `{path, reason}`; the table form writes the same lines to stderr. Added `read_page_strict()`
|
|
for the future ingest-quarantine path (#32), which must stop on unparseable input rather than
|
|
empty it.
|
|
6. *Corpus reparse per call.* New `chemenu/corpus_cache.py`: a `CorpusCache` object a caller
|
|
*holds* (not a module-global switch). The CLI holds none and is unaffected. Correctness
|
|
property, not just speed: **a dirty working tree is never cached** - if git cannot answer
|
|
whether the tree is clean, it is treated as dirty. `wikitool search` end-to-end (best of 5):
|
|
**593 ms → 347 ms**.
|
|
|
|
Bumped `--patch` → **2.2.4**.
|
|
|
|
**#31 - Root resolution + library boundary.** `config.ROOT` and everything derived from it were
|
|
Python module constants, bound at import time from `Path(__file__).resolve().parents[2]` - so
|
|
`monkeypatch.setattr(config, "ROOT", tmp_path)` repointed `ROOT` but left `KB_DIR`/`RAW_DIR`
|
|
aimed at the real checkout, the exact failure class `raw_dir`'s own fixture docstring already
|
|
warned about one layer up.
|
|
|
|
Rewrote `config.py`: `resolve_root()` by precedence (explicit argument → `$CHEMENU_ROOT` →
|
|
walk-up, the walk-up staying default so `tools/wikitool` is unaffected), and every derived path
|
|
resolved on *attribute access* via module `__getattr__` (PEP 562) rather than stored - so an
|
|
assignment onto `ROOT` (test or otherwise) is honored by every path under it, live. This
|
|
surfaced a subtler bug while fixing the first one: `monkeypatch`'s own undo mechanism reads the
|
|
old value (which resolves it) and writes it back as a real attribute on teardown, recreating the
|
|
stale binding the rewrite was meant to eliminate. Added `config.reset()`, called by the autouse
|
|
hermetic-environment fixture on both setup and teardown.
|
|
|
|
Split the CLI-coupled command modules into a pure core + thin adapter, three times over:
|
|
`search/service.py`, `lint_core.py`, `types_core.py` - none of them import `typer` or `rich`.
|
|
`commands/search.py`, `commands/lint.py`, `commands/types_cmd.py` became the terminal adapters,
|
|
re-exporting the same names so no existing import breaks.
|
|
|
|
New `chemenu/errors.py`: `ChemenuError` → `ValidationError` (also inherits `ValueError`, since
|
|
`PredicateError` already was one and callers catch it that way) / `BackendError`.
|
|
`PredicateError`, `FrontmatterError`, `UnknownBackend`, `RipgrepMissing`, `RipgrepFailed` moved
|
|
under this hierarchy.
|
|
|
|
New `chemenu/api.py`: `Corpus` class, the in-process entry point - takes a root, returns exactly
|
|
the CLI's `--json` shapes, raises instead of exiting, stamps every response with `commit`/
|
|
`as_of`. Wrote the acceptance test by monkeypatching `Path.read_text`/`Path.rglob` to fail on any
|
|
access under the real checkout root while a foreign root is set - this test could not have
|
|
passed before the lazy-resolution rewrite.
|
|
|
|
Two more accidental dependencies surfaced and were fixed: `TypeResolver.repo_root` was also
|
|
import-bound (fixed the same way, with `config.rooted()` as a process-wide context manager for
|
|
callers that reach `config` directly rather than taking a root argument - and `use_shipped_type_specs()`
|
|
added to the test fixtures that had been relying on it silently); and `search/registry.resolve()`
|
|
did not pass `kb_dir`/`root` through to the backend, so a caller pointing `run_search` at a
|
|
foreign corpus could still have `RipgrepBackend` read `config.KB_DIR` underneath it.
|
|
|
|
Bumped `--minor` → **2.3.0** (new capability, backward-compatible).
|
|
|
|
**#19 - MCP read server.** New package `tools/chemenu/mcp/` (`server.py`, `__main__.py`).
|
|
Installed the `mcp` SDK (`mcp>=2.0`, discovered mid-session that this pulled in the v2 API -
|
|
`FastMCP` renamed to `MCPServer`, imported from `mcp.server.mcpserver`). Five tools over
|
|
`chemenu.api.Corpus`: `search`, `types`, `describe_type`, `lint`, `status` (`status` is
|
|
server-composed, not a wrapper - there is no `wikitool status` command to wrap). No write tool,
|
|
structurally: neither the server module nor `chemenu.api` imports anything under
|
|
`chemenu.commands`.
|
|
|
|
Both transports built and smoke-tested end-to-end against the real 176-page corpus: `stdio` via
|
|
a hand-written MCP client script, and `streamable-http` (host/port bound explicitly - the
|
|
default binds loopback, wrong for a container behind a proxy) via a subprocess + HTTP client
|
|
round-trip. `sse` deliberately not offered (superseded transport).
|
|
|
|
`ChemenuError` translated to the SDK's `ToolError` at the tool-call boundary (a deliberate
|
|
refusal whose message reaches the caller) rather than left to become an `UnexpectedToolError`
|
|
(a crash whose message stays server-side).
|
|
|
|
Found and fixed a stamping bug while writing the golden test: `_stamp()` was asking the cache for
|
|
the *current* revision after the load had already happened, so a caller with a perfectly clean
|
|
tree could see `"commit": null` if the cache's cached revision lagged. Fixed by threading the
|
|
revision the load actually returned through to the stamp.
|
|
|
|
Wrote `tools/chemenu/tests/test_mcp_server.py`: the golden test runs `wikitool ... --json` as a
|
|
subprocess against the same fixture tree (via `$CHEMENU_ROOT`) and asserts the server's
|
|
structured output matches field-for-field; a before/after test captures file size+contents,
|
|
`git rev-parse HEAD`, and `git status --porcelain` around all five tool calls to prove nothing
|
|
writes; a structural test imports the server module in a fresh interpreter and checks
|
|
`sys.modules` for absence of `chemenu.commands.*`.
|
|
|
|
Added `check_trace_destination()`: the server **refuses to start** if `WIKI_TRACE` is on and
|
|
would write into the served checkout's `reports/telemetry/` - the directory the sync's
|
|
`git reset --hard` is entitled to wipe. `WIKI_TRACE=0` or `WIKI_TRACE_DIR` outside the corpus
|
|
both satisfy it.
|
|
|
|
New `instructions/mcp-read-server.md` (agent-facing operating procedure), `tools/requirements-mcp.txt`
|
|
(the SDK's dependency, kept out of `requirements.txt` so a CLI-only instance need not install
|
|
pydantic/starlette/uvicorn/cryptography), a CI step to install it (so the golden test cannot
|
|
silently be skipped), and doc updates (`tools/CONTRACT.md`, `tools/README.md`, `README.md`).
|
|
|
|
Bumped `--minor` → **2.4.0**.
|
|
|
|
Ran `docs verify`, `instructions verify`, and the full suite (**799 passed**) plus an
|
|
empty-machine run (`env -i ... pytest`) with the same result, before presenting the changeset.
|
|
|
|
`tools/wikitool publish` hit the **Mass-Update Gate**: 37 files, +3,037/-625 lines, exit 42.
|
|
Reproduced the full file-by-area breakdown verbatim in the reply (as the gate's message
|
|
requires - the output does not reach the user's screen on its own) and stopped without running
|
|
further commands, per the gate's instruction and AGENTS.md invariant 6.
|
|
|
|
## Turn 2 - "Freigegeben. Erstelle eine install-MCP.md für Menschen. Für traefik middleware verweise auf https://gitea.nehmer.net/torben/gitea-mcp-forward-auth ... Wir brauchen ein issue, der uns dicker Container für ein deployment erzeugt. Fasse dort schon mal zusammen, was du bisher weißt... Die ci scripte in https://gitea.nehmer.net/torben/gitea-mcp dürfte ein guter startpunkt sein."
|
|
|
|
Published with the token from the gate's own printed line
|
|
(`--confirm 46442f4419c1`) - commit `576df2c`.
|
|
|
|
Fetched both referenced repos via `gitea-mcp` (`get_repository_tree`, `get_file_contents`) to
|
|
ground the two follow-up documents in what actually exists rather than in assumption:
|
|
|
|
- `gitea-mcp-forward-auth`: a small Go Traefik ForwardAuth service - Bearer-token-vs-SHA-256-hash
|
|
checking, `AUTH_PROXY_TOKEN_HASHES_DIR`/`AUTH_PROXY_TOKEN_HASHES` config, `/healthz` always
|
|
open, demo token hashes checked into the repo, a registry-image test script with ephemeral
|
|
Docker/Podman login.
|
|
- `gitea-mcp`: its Dockerfile (multi-stage, `CGO_ENABLED=0`, distroless non-root, OCI labels) is
|
|
a usable pattern, but its release workflow pushes to **DockerHub** (`DOCKER_ORG: gitea`) - it
|
|
is a fork of the upstream project, so it is *not* a usable registry-push template for this
|
|
repo. Noted explicitly rather than silently copied.
|
|
|
|
Wrote `install-MCP.md` (later renamed, see Turn 3): six numbered steps (install dependency, run
|
|
stdio, wire a client, run streamable-http, put authentication in front, keep the corpus current
|
|
via polling), a runnable stdio verification script (executed for real before being written into
|
|
the doc, output: `Tools: [...]`, `Seiten: 176 | Commit: <sha>`), and a troubleshooting section
|
|
keyed to the server's actual error messages. Linked it from `INSTALL.md` and `README.md`, and
|
|
added `CHEMENU_ROOT`/`WIKI_TRACE`/`WIKI_TRACE_DIR` to `INSTALL.md`'s configuration table (both
|
|
had been in effect since earlier work but undocumented there). Added the file to `dist_cmd.py`'s
|
|
`ROOT_FILES` allowlist and verified with a real `dist export` that it ships.
|
|
|
|
Filed **issue #37** ("Container-Image für den MCP-Leseserver"): summarized what is already fixed
|
|
(startup command, required env vars, the ripgrep-in-the-image trap a naive `pip install`
|
|
Dockerfile would miss, where auth/rate-limiting belong) and the concrete templates found above,
|
|
then nine explicit open decisions (corpus baked into the image vs. mounted as a volume and
|
|
synced by a sidecar; who runs the sync; base image, since Go-style `distroless` does not carry a
|
|
Python interpreter or `rg`; how the version reaches the image; whether to build `arm64`;
|
|
**a healthcheck endpoint does not exist yet - the one item that implies new code in this repo**;
|
|
registry path; OCI labels; whether a smoke test against the built image is added). Labeled
|
|
`prio/2 size/M`.
|
|
|
|
Closed **#34, #33, #31, #19** with detailed comments each restating what was implemented against
|
|
the issue's own acceptance criteria, including the two accidental-dependency findings from #31
|
|
and the corrected alias-bomb numbers. Commented on **#36** with a summary table across all four
|
|
versions, noted its closing criterion (a consumer provably reaching the server through the
|
|
Traefik middleware) is not yet met - that needs #37's deployment - and proposed leaving #36 open
|
|
until then; also carried forward its two remaining loose ends (the wrong `qmd.md` language claim,
|
|
and the still-missing place for architecture decisions).
|
|
|
|
## Turn 3 - "Freigegeben. Nenne die Datei INSTALL-MCP.md all Caps sonst fahre mit dem Auftrag fort" (interrupting a `dist export` verification call)
|
|
|
|
Renamed `install-MCP.md` → `INSTALL-MCP.md`, fixed every reference (`INSTALL.md`, `README.md`,
|
|
`tools/chemenu/commands/dist_cmd.py`). Re-ran the full suite (**799 passed**) and `docs verify`,
|
|
bumped `--patch` → **2.4.1**, wrote the changelog entry, published (commit `83018fc`). Saved a
|
|
feedback memory (`root-docs-are-all-caps.md`): root-level human docs in this repo are named in
|
|
ALL CAPS, and a new one has to be added to `dist_cmd.ROOT_FILES` or it silently does not ship.
|
|
|
|
## Outcome
|
|
|
|
- **Version:** 2.2.2 -> 2.2.3 -> 2.2.4 -> 2.3.0 -> 2.4.0 -> 2.4.1
|
|
- **Commits:** `576df2c` (2.4.0, the 37-file changeset cleared through the Mass-Update Gate),
|
|
`83018fc` (2.4.1, `INSTALL-MCP.md` and its wiring)
|
|
- **Tests:** 776 -> 786 -> 799 passed, green throughout, including on an `env -i` empty machine
|
|
- **Measured:** corpus parse 265ms -> 54ms; `wikitool search` end-to-end 593ms -> 347ms
|
|
- **Issues:** #34, #33, #31, #19 closed with detailed comments; #36 commented, left open pending
|
|
#37; #37 opened (`prio/2 size/M`)
|
|
- **CI:** `.gitea/workflows/ci.yml` updated to install `tools/requirements-mcp.txt`, otherwise
|
|
unchanged; not separately re-run in this session (publish triggers it)
|
|
- **Not done in this session, carried forward:** #37 itself (container image); #23 (env var
|
|
registration enforcement - `CHEMENU_ROOT` was added to `_WIKITOOL_ENV` by hand); the `qmd.md`
|
|
language-claim fix (separate transcript); an ADR-style home for architecture decisions
|