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:
parent
5f1772e832
commit
df5a1183c9
10 changed files with 1823 additions and 59 deletions
170
docs/2026-09-20-mcp-to-varianter.md
Normal file
170
docs/2026-09-20-mcp-to-varianter.md
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
# An MCP surface over OKF bundles, in two shapes
|
||||
|
||||
2026-09-20. Order `20260918T163400Z-6303812376-from-.claude`. 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 <the frozen set>/sporsmal.json \
|
||||
--frys <the frozen set>/frys.json \
|
||||
--bundle-root <a directory holding its bundles>
|
||||
```
|
||||
|
||||
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`):
|
||||
**280 of 312 and 310 lines identical, 62 lines differing**. 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 <bundle>`, **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.
|
||||
|
||||
## 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`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue