# An MCP surface over OKF bundles, in two shapes 2026-09-20. Capability loop: the eval was written RED at `5f1772e`, before any server existed; the capability follows in its own commit. The operator's question was not "does MCP work". It was: one server per bundle or one server for many, and **must these artefacts be made again every time a bundle is rebuilt or a new one appears?** This round builds the three artefacts that question compares, and measures the answer. ## What was measured, and against what `tools/okf_mcp_gate.py`, six rows, one exit code. The server is started as a subprocess and spoken to over newline-delimited JSON-RPC beginning at `initialize` -- never imported. A client built from the server's own framing helpers would agree with the server by construction, so the client is written separately in the gate. Denominators are pinned in the gate and recounted a second time in the tests: 7 required tools across the two shapes, 4 artefact classes, 3 bundles times 3 discovery checks, 3 cross-bundle checks, 6 hostile cases. A row that counted what the server happened to offer would go green by offering less. | row | what it asks | today | |---|---|---| | 1 | every required tool answers over real stdio, carrying bundle id and concept id | **7 of 7** | | 2 | every anchor the frozen graded set points at, fetched verbatim | **83 of 181** | | 3 | one concept changes: does the stale artefact refuse, or answer quietly | **4 of 4** | | 4 | three unknown bundles appear while the server runs | **9 of 9** | | 5 | one documented sequence, two bundles, both sources | **3 of 3** | | 6 | traversal, symlink, broken manifest, 10 MB concept, unknown id | **6 of 6** | `GATE RED: rows 2`, exit 1. Reproduce: ```sh uv run python tools/okf_mcp_gate.py \ --sett /sporsmal.json \ --frys /frys.json \ --bundle-root ``` Without the last three flags row 2 is `0 of 0` with the reason stated: the set names a consumer's documents, this repository is public, and a gold set is an input here and never a constant. ## Row 3 is the operator's question, and the answer has four rows The drill: copy a bundle, start the artefact, change one concept, ask again. | artefact | stale answer | artefacts to remake | manual steps | |---|---|---|---| | one server in front of one bundle | refuses / cannot go stale | 0 | 0 | | one server in front of many | refuses / cannot go stale | 0 | 0 | | today's generated skill (per bundle) | refuses out loud (`bundle_mismatch`) | 1 | 1, **per consuming project** | | the generic skill (one for all) | cannot go stale | 0 | 0 | **Neither MCP shape needs an update when a bundle is rebuilt, and neither needs one when a bundle is added.** That is not luck: nothing is cached across calls. Every call re-walks the roots 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. The cost is real and is paid per call -- see the limits below. Row 3 was **1 of 4 before any capability existed**, which the order did not predict and is worth stating: today's per-bundle skill already refuses out loud when its bundle moves, because `okf check`'s `bundle_mismatch` rule compares the declared ref against the payload's. The skill's cost is not silence. It is that one artefact has to be regenerated and reinstalled wherever it was installed, and that number is not measurable from inside this machine. ## The generic skill, measured rather than assumed The order cited 227 of 285 lines identical between two generated skills, measured 2026-09-18. Measured again here, on two different bundles (`examples/ingest-golden-segmented-okf-v0-2` and `tests/fixtures/consume-bundle`): **281 of 313 and 311 lines identical, 62 lines differing** (re-measured 2026-09-20 after the breaking-point sentence was repaired; it was 280 of 312 and 310, with the same 62). Neither number contradicts the other -- they are different pairs of bundles -- and the shape of the finding is the same: what differs is identity, concept count, the conditional-field table, the whole-bundle cost and the breaking point. `skill.render_generic()` carries none of them. The property that makes that claim checkable rather than asserted is that **the function takes no argument**: there is no bundle it could have read, and two calls return the same bytes. A test controls it against a per-bundle skill, which must carry exactly what the generic one does not -- without that control, an assertion about an absence passes on an empty string. The per-bundle half is `okf card `, **derived on every run and never written into the bundle**. The order proposed storing it there. Writing a card file into every bundle would move the bytes of all six `examples/*/expected-bundle` trees (23 files compared byte-for-byte) and of the pinned reference bundle, to store something recomputable in under a second -- and a stored card is one more artefact that can disagree with the bytes beside it, which is the defect the generic skill exists to remove. Chosen as derived because it answers the maintenance question more completely, not less. ## Row 2 decomposed: the bundle, the ranker, and the vocabulary **83 of 181** (bundle, anchor) pairs, `M = 181` counted from the set at run time. The order's own figure of 197 is the set's atom count under a different definition; 181 is what the pair rule below yields on the file as frozen at version 4. Three numbers, and the middle one is the finding: * **99 of 181 pairs are present in the bundles at all.** 82 are not: the text the set quotes is not in the bundle, which is red for the BUNDLE and not for the server. `r761-2025` is the sharpest case at 17 of 33 present. * **83 of the 99 present were reached**, so the surface reaches 83.8 % of what is there. `r761-2025` is again the outlier: 2 reached of 17 present. * **0 of 83 were met by `okf_fetch` on the anchor as a concept id.** The set's anchors (`Krav 2.3.1—3`) and this library's concept ids are different vocabularies, so the cheap route -- a true ceiling -- never fires, and every pair met was met through `okf_ask`, which runs the ranker. **That makes 83 a FLOOR on the ceiling, never the ceiling.** A surface offering a lookup by the publisher's own anchor would separate the two, and does not exist today. Quote comparison folds exactly two things and nothing else: U+00AD, because `okf build` strips soft hyphens from extracted text while the publisher's JSON keeps them, and whitespace runs, because a quote cut out of a paragraph carries the line breaks of wherever it was cut. Case is not folded. ## Hostile input, and why a code set rather than "was refused" Row 6 declares, per case, the refusal CODES that count as the right refusal. The first run of this gate had the 10 MB concept refused as `concept_unknown` -- the fixture had written the file without naming it in the index, so the size ceiling never ran and the row was green for a reason unrelated to the attack. Two checks giving the same verdict are not the same guarantee. Containment is two independent checks: the bundle's own index must name the concept, AND the resolved path must be inside the bundle. A mutant removing the first one **survives**, and the mechanism is printed: the traversal is then refused by the second, as `path_escape` instead of `concept_unknown`. A mutant removing both is killed. That survival is the redundancy working and is reported as such rather than as a kill. **A note added 2026-09-20, after this round:** that sentence was true of `okf_fetch` and of no other tool. `okf_ask` and `okf_describe` made only the first of the two checks -- the index rule, which is a string rule and cannot see a symlink -- and read whatever the joined path pointed at. The second check now lives in `consume.resolve_in_bundle` and every reader here goes through it; the tests are `tests/test_read_path_containment.py`, red on 8 of 11 rows before the repair with `okf_fetch`'s two rows green as the control. ## Mutants 13 mutants, applied in a scratch copy of the tree and never in the working tree, with an unmutated control first: **12 killed, 1 survived with a mechanism, 0 errors.** The control's gate rows and pytest targets are green before the first mutation, so a kill cannot be the call having failed. Killed: a cached bundle identity (row 3), two bundles known by name in the many-shape (row 4), a fetched concept without its concept id (row 1), both containment checks removed (row 6), discovery run once at startup (row 4), row 2's denominator taken from the run (test), a symlink descended (test), the size ceiling removed (row 6), the generic skill naming a bundle (test), a broken manifest skipped silently (row 6), a listing tool on the one-shape (test), and an unknown bundle answered instead of refused (row 6). ## Limits, stated rather than implied * **Nothing is cached, and it costs.** On the 2 756-concept bundle the content identity is a 0.75 s hash of the whole concept tree and one `okf_ask` is 5.6 s. Row 2's full run over four bundles and 181 pairs took **4 min 13 s**. A cache would have to be keyed on something cheaper than the hash and still correct; no such key is shipped, and the cost is the price of the row-3 result above. * **The gate measures a ceiling and a maintenance cost.** Whether an arm answers WELL is a different question, asked by `tools/okf_retrieval_gate.py`. No arm was run here and no model was called. * **The architecture choice is the operator's.** These rows are its input. * Row 3 counts artefacts and steps inside this machine. A project that has installed a generated skill pays one more step per project, and that number is not measurable from here. * No MCP server was registered in any `settings.json` or `.mcp.json`.