feat(mcp): serve OKF bundles over MCP in two shapes, plus the generic skill

The eval was written RED at `5f1772e` with no server in the tree. This is the
capability it was written against.

`okf mcp --bundle <dir>` serves exactly one bundle, whose tools take no bundle
argument. `okf mcp --root <dir>` (repeatable) serves every bundle under the
roots and knows NONE of them by name. Four tools -- `okf_list`,
`okf_describe`, `okf_ask`, `okf_fetch` -- each carrying its reason in the
description a client actually reads.

Gate today: 1 (7/7) - 2 (83/181) - 3 (4/4) - 4 (9/9) - 5 (3/3) - 6 (6/6),
`GATE RED: rows 2`, exit 1.

THE PROTOCOL IS STDLIB, AND THAT IS THE PACKAGING INVARIANT KEPT RATHER THAN
A TASTE. An MCP SDK would be this package's second runtime dependency on the
DEFAULT install path, for four JSON-RPC methods and a newline framing, and
`test_the_only_runtime_dependency_is_the_security_boundary` pins that list
literally. Chosen hand-written because the surface needed is `initialize`,
`notifications/initialized`, `tools/list` and `tools/call`; `uv.lock` is
untouched.

NOTHING IS CACHED ACROSS CALLS, and row 3 is why. Every call re-walks the
roots and recomputes `bundle_ref`, so a bundle added, removed or rebuilt while
the process runs is seen by the next call with no restart, no configuration
edit and no code change -- 9 of 9 discovery checks over three bundles written
while the server was serving. The cost is paid per call and is published
rather than hidden: 0.75 s for the identity of a 2 756-concept bundle, 5.6 s
for one ask, 4 min 13 s for row 2's full run over four bundles.

CONTAINMENT IS TWO INDEPENDENT CHECKS: the bundle's own index must name the
concept, AND `connectors.safe_resolve` must place it inside the bundle. A
mutant removing either one alone still refuses -- with a DIFFERENT code, which
row 6 asserts by name -- and one removing both is killed. Row 6 declares a
code set per case because its first run had the 10 MB concept refused as
`concept_unknown`: the fixture had not named the file in the index, so the
size ceiling never ran and the row was green for a reason unrelated to the
attack.

`okf card <bundle>` and `okf skill --generic` are the one-to-many skill
candidate. The card is DERIVED on every run and never written into the bundle:
storing it would move the bytes of all six `examples/*/expected-bundle` trees
(23 files compared byte-for-byte) and of the pinned reference bundle, to keep
something recomputable in under a second, and a stored card is one more
artefact that can disagree with what is beside it. Measured here rather than
taken from the order: two per-bundle skills are identical on 280 of 312 and
310 lines; the 62 that differ are identity, concept count, the
conditional-field table, the whole-bundle cost and the breaking point. The
generic skill carries none of them, and `render_generic()` takes no argument,
so there is no bundle it could have read.

Row 2 decomposes into three numbers and the middle one is the finding: 99 of
181 (bundle, anchor) pairs are present in the bundles at all, 83 of those 99
were reached, and 0 of 83 were met by `okf_fetch` on the anchor as a concept
id. The set's anchors and this library's concept ids are different
vocabularies, so every pair met was met through the ranker -- 83 is a FLOOR on
the ceiling, never the ceiling.

13 mutants in a scratch copy, never in the working tree: 12 killed, 1 survived
with its mechanism printed, 0 errors, control green first. Suite 2323 passed,
2 skipped. The architecture choice between the two shapes is the OPERATOR's;
these rows are its input. Report: docs/2026-09-20-mcp-to-varianter.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-20 10:25:55 +02:00
commit df5a1183c9
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
10 changed files with 1823 additions and 59 deletions

View file

@ -1155,6 +1155,81 @@ the summary back. Install it for your user account after cloning:
mkdir -p ~/.claude/skills && cp -R skills/okf-prosjekt ~/.claude/skills/
```
## Serve a bundle over MCP: `okf mcp`
Two shapes, one implementation, and the difference is what an agent has to be
told in advance.
```sh
okf mcp --bundle .okf/my-bundle # one server, one bundle
okf mcp --root ~/bundles --root ./.okf # one server, every bundle under the roots
```
`--bundle` serves exactly one bundle, fixed at startup; its tools take no
bundle argument, because there is nothing to choose. `--root` (repeatable)
serves every bundle found under the given directories and **knows none of them
by name**: it discovers them per call, so a bundle you add, remove or rebuild
while the server is running is picked up by the next call. No restart, no
configuration edit, no code change.
Four tools, and each one's description says why it exists:
| tool | what it answers |
|---|---|
| `okf_list` | which bundles are reachable right now, with each one's content identity and concept count (multi-bundle servers only) |
| `okf_describe` | what one bundle is: id, ref, concept count, source documents, and how many concepts carry each conditionally-written field |
| `okf_ask` | one question, one bounded payload of excerpts, each with its bundle id, concept id, title and provenance locators. Omitting `bundle_id` on a multi-bundle server asks them all and splits the budget |
| `okf_fetch` | one named concept, verbatim, with its frontmatter and locators |
**Nothing is cached between calls, and that is the design.** Every call
re-reads the directories and recomputes the bundle's content identity, so the
identity in an answer is a fact about the bytes at the moment of the call
rather than at startup — a server that answered from yesterday's bundle is the
one failure you cannot see from the outside. The cost is real and is paid per
call: on a 2 756-concept bundle the identity is a 0.75 s hash of the whole
concept tree, and one `okf_ask` is 5.6 s.
**Refusals are loud.** A path climbing out of the bundle, a symlink leaving the
served root, a bundle id nobody answers to, a directory whose manifest cannot be
read, and a concept above the server's size ceiling each come back as an error
with a code — never as a plausible-looking empty answer. A concept over the
ceiling is refused whole rather than truncated: a truncated concept read as
whole is a wrong answer that looks right. A directory that cannot be read as a
bundle is **reported** in `okf_list`'s `unreadable`, not skipped.
The protocol is written with the standard library only. An MCP SDK would be
this package's second runtime dependency on the default install path, for four
JSON-RPC methods and a newline framing — see
[Requirements](#requirements).
`tools/okf_mcp_gate.py` is the eval: it starts the server as a subprocess,
speaks real stdio to it, and measures six rows. It was written red before the
server existed, and it is red today on row 2. The measurements, the update
drill and the limits are in
[`docs/2026-09-20-mcp-to-varianter.md`](docs/2026-09-20-mcp-to-varianter.md).
### One skill for every bundle: `okf card` and `okf skill --generic`
`okf skill <bundle>` writes a consumption skill for **that** bundle, with its
identity and its numbers measured into the text — which is what makes the file
stale the moment the bundle is rebuilt. `okf skill --generic` writes one
installable skill for **any** bundle instead:
```sh
okf skill --generic --out ~/.claude/skills/okf-consume-any
okf card .okf/my-bundle # the per-bundle numbers, as JSON, on demand
```
The generic skill carries no bundle's id, no ref and no count; it tells its
reader to run `okf card <bundle>` first. The card is **derived on every run and
never written into the bundle**, so there is no second artefact that can
disagree with the bytes beside it.
Measured on two unrelated bundles: two per-bundle skills are identical on 280
of 312 and 310 lines. The 62 lines that differ are exactly identity, concept
count, the conditional-field table, the whole-bundle cost and the breaking
point — the five things a rebuild invalidates.
## Implemented scope (v1)
The library provides three entry points for getting content into an OKF