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
35
CHANGELOG.md
35
CHANGELOG.md
|
|
@ -9,6 +9,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Added
|
||||
|
||||
- **An MCP surface over OKF bundles, in two shapes, plus a generic
|
||||
consumption skill.** `okf mcp --bundle <dir>` serves exactly one bundle;
|
||||
`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 with its reason written into the description a
|
||||
client reads. The eval was written RED first (`tools/okf_mcp_gate.py`,
|
||||
`5f1772e`); the capability follows.
|
||||
- **The protocol is written narrowly with stdlib only, and that is the
|
||||
packaging invariant kept rather than an aesthetic.** 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. `uv.lock` is untouched.
|
||||
- **Nothing is cached across calls.** Every call re-walks the roots and
|
||||
recomputes the bundle's content identity, so a bundle added, removed or
|
||||
rebuilt while the server runs is seen by the next call without a restart, a
|
||||
configuration edit or a code change — measured, 9 of 9 discovery checks over
|
||||
three bundles written while the process was serving. The cost is paid per
|
||||
call: 0.75 s for the identity of a 2 756-concept bundle, 5.6 s for one ask.
|
||||
- **Containment is two independent checks**: the bundle's own index must name
|
||||
the concept, and the resolved path must be inside the bundle. Removing
|
||||
either one alone still refuses — with a different code, which the gate
|
||||
asserts by name — and removing both is caught by the gate's row 6.
|
||||
- **`okf card <bundle>`** prints one bundle's identity, concept count,
|
||||
conditional-field counts and whole-bundle cost as JSON, DERIVED on every run
|
||||
and never written into the bundle. **`okf skill --generic`** writes one
|
||||
installable consumption skill for ANY bundle, carrying no bundle's identity
|
||||
or numbers and pointing its reader at the card. Measured: two per-bundle
|
||||
skills are identical on 280 of 312 and 310 lines, and what differs is
|
||||
exactly what goes stale on a rebuild.
|
||||
- Report: `docs/2026-09-20-mcp-to-varianter.md`. The gate stands RED on row 2
|
||||
(83 of 181 anchors of the frozen graded set reached, of which 99 are present
|
||||
in the bundles at all and 0 were met by a concept-id lookup), and the
|
||||
architecture choice between the two shapes is the operator's.
|
||||
|
||||
- **Every carried image is now one a model can be SHOWN, and the ones that
|
||||
cannot be are refused out loud.** Until this round the asset path carried
|
||||
whatever format a publisher shipped. Measured 2026-09-19 over the frozen
|
||||
|
|
|
|||
51
CLAUDE.md
51
CLAUDE.md
|
|
@ -1406,6 +1406,57 @@ and fixtures, never code.
|
|||
held by a test. README publishes this bar behind
|
||||
`<!-- quality-boundary-threshold: ... -->`; SS 7 of the threshold document
|
||||
carries the seven bundles and the honesty limits.
|
||||
- **Serve a bundle over MCP: `okf mcp --bundle <dir>` (one bundle) or
|
||||
`okf mcp --root <dir>` (every bundle under the roots, none known by name).**
|
||||
Four tools -- `okf_list`, `okf_describe`, `okf_ask`, `okf_fetch` -- each
|
||||
carrying its REASON in the description a client reads, and every answer
|
||||
carrying the bundle id and concept id a claim must be attributed to. The
|
||||
JSON-RPC is written narrowly with stdlib only: an MCP SDK would be this
|
||||
package's SECOND runtime dependency on the default install path, and
|
||||
`test_the_only_runtime_dependency_is_the_security_boundary` pins that list
|
||||
literally; `uv.lock` is untouched. **NOTHING IS CACHED ACROSS CALLS** -- 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 config edit and no code change (measured, 9 of 9 discovery checks
|
||||
over three bundles written while serving). The cost is paid per call and is
|
||||
published: **0.75 s** for the identity of a 2 756-concept bundle, **5.6 s**
|
||||
for one ask. Containment is TWO independent checks -- the bundle's own index
|
||||
must name the concept AND `connectors.safe_resolve` must place it inside the
|
||||
bundle -- and a mutant removing either one alone still refuses, with a
|
||||
different code. A concept above `MAX_CONCEPT_BYTES` is refused whole rather
|
||||
than truncated, and a directory that cannot be read as a bundle is REPORTED
|
||||
in `okf_list`'s `unreadable` rather than skipped: an absence with no
|
||||
denominator is not a boundary. The eval is
|
||||
`tools/okf_mcp_gate.py`, written RED at `5f1772e` before any server existed;
|
||||
it speaks real stdio to a SUBPROCESS and never imports the server. Today:
|
||||
**1 (7/7) - 2 (83/181) - 3 (4/4) - 4 (9/9) - 5 (3/3) - 6 (6/6)**,
|
||||
`GATE RED: rows 2`. **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
|
||||
and 83 is a FLOOR on the ceiling, never the ceiling. **The architecture choice
|
||||
between the two shapes is the OPERATOR's**; the rows are its input. Report:
|
||||
`docs/2026-09-20-mcp-to-varianter.md`.
|
||||
- **`okf card <bundle>` and `okf skill --generic` are the one-to-many skill
|
||||
candidate.** The card is one bundle's identity, concept count,
|
||||
conditional-field counts and whole-bundle cost as JSON, **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 the
|
||||
bytes beside it. `okf skill --generic` writes ONE installable consumption
|
||||
skill for ANY bundle: it carries no bundle's identity and no bundle's numbers,
|
||||
and the property that makes that checkable rather than asserted is that
|
||||
`skill.render_generic()` **takes no argument** -- there is no bundle it could
|
||||
have read. Measured 2026-09-20: two per-bundle skills are identical on **280
|
||||
of 312** and **310** lines, and the 62 that differ are exactly identity,
|
||||
concept count, the conditional-field table, the whole-bundle cost and the
|
||||
breaking point -- the five things that go stale on a rebuild. The update
|
||||
drill, four artefact classes: MCP one-to-one **0 artefacts / 0 steps**, MCP
|
||||
one-to-many **0 / 0**, today's per-bundle skill **1 / 1 per consuming
|
||||
project** (it refuses out loud through `bundle_mismatch`, so its cost is not
|
||||
silence), generic skill **0 / 0**.
|
||||
- Consume a bundle: `okf consume <bundle> --question "<q>"
|
||||
[--k N] [--limit N] [--out PATH] [--ref IDENTITY]` — the **pre-pass**
|
||||
`docs/consumption-contract.md` § 1 defines, and the only reading direction
|
||||
|
|
|
|||
75
README.md
75
README.md
|
|
@ -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
|
||||
|
|
|
|||
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`.
|
||||
|
|
@ -105,7 +105,7 @@ __all__ = ["DEFAULT_STAMP", "build", "main", "measure"]
|
|||
#:
|
||||
#: Imported lazily inside the dispatch: `okf build` should not pay to import
|
||||
#: the ranker, and `okf consume` should not pay to import the proposer.
|
||||
DELEGATED = ("consume", "check", "skill", "project", "quality")
|
||||
DELEGATED = ("consume", "check", "skill", "project", "quality", "card", "mcp")
|
||||
|
||||
|
||||
def _delegate(command: str, argv: list[str]) -> int:
|
||||
|
|
@ -117,6 +117,10 @@ def _delegate(command: str, argv: list[str]) -> int:
|
|||
from .skill import main as run
|
||||
elif command == "quality":
|
||||
from .quality import main as run
|
||||
elif command == "card":
|
||||
from .skill import card_main as run
|
||||
elif command == "mcp":
|
||||
from .mcp_server import main as run
|
||||
else:
|
||||
from .project import main as run
|
||||
return run(argv)
|
||||
|
|
@ -648,6 +652,8 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||
("skill", "instantiate the consumption skill template for one bundle"),
|
||||
("project", "folder in, bundle plus skill out: build and skill in one step"),
|
||||
("quality", "judge one bundle per file type, with the denominator"),
|
||||
("card", "print one bundle's own identity, counts and denominators as JSON"),
|
||||
("mcp", "serve one bundle, or every bundle under a root, over MCP on stdio"),
|
||||
):
|
||||
subcommands.add_parser(delegated, help=blurb, add_help=False)
|
||||
build_parser = subcommands.add_parser(
|
||||
|
|
|
|||
737
src/llm_ingestion_okf/mcp_server.py
Normal file
737
src/llm_ingestion_okf/mcp_server.py
Normal file
|
|
@ -0,0 +1,737 @@
|
|||
"""Expose OKF bundles over the Model Context Protocol, in two shapes.
|
||||
|
||||
Beside `skill.py` because it belongs to the same class: a way to put a bundle
|
||||
in front of an agent. The skill hands a consumer a document telling it which
|
||||
command to run; this hands it a set of tools a client calls. Neither ranks
|
||||
anything of its own -- both reach `consume.build_payload`, which stays the one
|
||||
reading direction this library has.
|
||||
|
||||
TWO SHAPES, ONE IMPLEMENTATION.
|
||||
|
||||
* `--bundle PATH` serves exactly ONE bundle, fixed at startup. The bundle
|
||||
tools take no bundle argument, because there is nothing to choose.
|
||||
* `--root PATH` (repeatable) serves every bundle found under the roots, and
|
||||
knows NONE of them by name. Discovery happens per call, so a bundle added,
|
||||
removed or rebuilt while the process runs is seen by the next call without a
|
||||
restart, a configuration edit or a code change.
|
||||
|
||||
NOTHING IS CACHED ACROSS CALLS, AND THAT IS THE DESIGN RATHER THAN AN
|
||||
OVERSIGHT. A server that read the bundle list once at startup would keep
|
||||
answering after the bundle was rebuilt, with an identity that no longer
|
||||
describes the bytes -- and an answer from yesterday's bundle is the one
|
||||
failure a consumer cannot see from the outside. Every call re-walks the roots
|
||||
and recomputes `bundle_ref`, so the identity in an answer is always a fact
|
||||
about the bytes on disk at the moment of the call. The cost is real: the
|
||||
identity is a sha256 over the whole concept tree, and it is paid per call.
|
||||
|
||||
WHY THE PROTOCOL IS WRITTEN HERE AND NOT TAKEN FROM AN SDK. This package
|
||||
declares exactly one runtime dependency, the security guard, and
|
||||
`tests/test_packaging.py::test_the_only_runtime_dependency_is_the_security_boundary`
|
||||
pins that list literally. An MCP SDK would be the second, on the DEFAULT
|
||||
install path, for four JSON-RPC methods and a newline framing -- so the
|
||||
protocol is written narrowly, with stdlib only, and the packaging invariant
|
||||
stays a fact rather than an intention. Chosen over the SDK because the surface
|
||||
needed is `initialize`, `notifications/initialized`, `tools/list` and
|
||||
`tools/call`, and nothing here needs resources, prompts, sampling or progress.
|
||||
|
||||
CONTAINMENT IS TWO INDEPENDENT CHECKS, NEVER ONE. A concept is reachable only
|
||||
if the bundle's own index names it (`consume.enumerate_concepts`, which
|
||||
refuses a target climbing above the root) AND its resolved path is inside the
|
||||
bundle (`connectors.safe_resolve`, on canonical paths). Either alone would be
|
||||
defensible; the pair is what makes a defect in one of them survivable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, TextIO
|
||||
|
||||
from . import consume as okf_consume
|
||||
from . import materialize
|
||||
from .connectors import safe_resolve
|
||||
from .errors import SourceError
|
||||
from .profiles import BundleProfile
|
||||
|
||||
#: The revision this server implements. A client asking for another is
|
||||
#: answered with this one, which the specification permits: the client then
|
||||
#: decides whether it can proceed.
|
||||
PROTOCOL_VERSION = "2025-06-18"
|
||||
|
||||
SERVER_NAME = "okf"
|
||||
|
||||
#: How deep a root is walked looking for bundles. A bundle is a directory with
|
||||
#: an `index.md` carrying a `bundle_id`, and the walk does NOT descend into one
|
||||
#: it has found -- a bundle inside a bundle is the door's own collision case,
|
||||
#: not a second bundle. Bounded rather than unbounded because a root is given
|
||||
#: by an operator and may be a home directory by accident.
|
||||
MAX_DISCOVERY_DEPTH = 3
|
||||
|
||||
#: The largest concept `okf_fetch` will hand over whole. A concept is a
|
||||
#: section of a document; this is two orders of magnitude above the largest in
|
||||
#: any bundle measured here, and it exists so that a bundle carrying a file
|
||||
#: that is not a concept cannot turn one tool call into a memory cost the
|
||||
#: caller never asked for. Refused with its own code, never truncated: a
|
||||
#: truncated concept read as whole is a wrong answer that looks right.
|
||||
MAX_CONCEPT_BYTES = 1024 * 1024
|
||||
|
||||
#: Default breadth of an `okf_ask`. The library's own default, restated here
|
||||
#: rather than imported implicitly, because a tool's default is part of its
|
||||
#: contract.
|
||||
DEFAULT_K = okf_consume.DEFAULT_K
|
||||
|
||||
|
||||
class ToolError(Exception):
|
||||
"""A refusal a client can act on. Always loud: it leaves the server as a
|
||||
JSON-RPC error, never as a plausible-looking empty answer."""
|
||||
|
||||
def __init__(self, message: str, *, code: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Discovery
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Served:
|
||||
bundle_id: str
|
||||
root: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Unreadable:
|
||||
"""A directory that looks like a bundle and cannot be read as one.
|
||||
|
||||
Reported rather than skipped. A broken manifest that simply vanishes from
|
||||
the list is an absence with no denominator, and the caller cannot tell it
|
||||
from a bundle that was never there.
|
||||
"""
|
||||
|
||||
path: str
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Discovery:
|
||||
bundles: tuple[Served, ...]
|
||||
unreadable: tuple[Unreadable, ...]
|
||||
|
||||
|
||||
def _declared_bundle_id(index: Path) -> str:
|
||||
frontmatter = materialize.parse_frontmatter(index)
|
||||
return str(frontmatter.get("bundle_id", "")).strip()
|
||||
|
||||
|
||||
def _walk(root: Path, depth: int) -> Iterator[Path]:
|
||||
"""Directories under `root`, breadth-first, to `MAX_DISCOVERY_DEPTH`.
|
||||
|
||||
A symlink is never descended and never yielded: a link inside a served
|
||||
root pointing outside it is exactly how a root boundary is escaped, and
|
||||
refusing to follow one is cheaper than proving each target is contained.
|
||||
"""
|
||||
if depth > MAX_DISCOVERY_DEPTH:
|
||||
return
|
||||
try:
|
||||
entries = sorted(root.iterdir(), key=lambda path: path.name)
|
||||
except OSError:
|
||||
return
|
||||
for entry in entries:
|
||||
if entry.is_symlink() or not entry.is_dir():
|
||||
continue
|
||||
yield entry
|
||||
if not (entry / "index.md").is_file():
|
||||
yield from _walk(entry, depth + 1)
|
||||
|
||||
|
||||
def _candidates(roots: Sequence[Path], *, include_roots: bool) -> Iterator[Path]:
|
||||
"""Directories to test for being a bundle.
|
||||
|
||||
`include_roots` is the whole difference between the two shapes at this
|
||||
level: `--bundle` points AT a bundle, `--root` points at a directory that
|
||||
holds them. Without it the one-to-one server discovers its own children and
|
||||
never itself -- which is how the first build of this module answered every
|
||||
call with "the bundle this server was started on is no longer readable".
|
||||
"""
|
||||
for root in roots:
|
||||
if include_roots:
|
||||
yield root
|
||||
else:
|
||||
yield from _walk(root, 1)
|
||||
|
||||
|
||||
def discover(roots: Sequence[Path], *, include_roots: bool = False) -> Discovery:
|
||||
"""Every bundle under the roots, recomputed on every call."""
|
||||
bundles: dict[str, Served] = {}
|
||||
unreadable: list[Unreadable] = []
|
||||
for candidate in _candidates(roots, include_roots=include_roots):
|
||||
index = candidate / "index.md"
|
||||
if not index.is_file():
|
||||
continue
|
||||
try:
|
||||
bundle_id = _declared_bundle_id(index)
|
||||
except (OSError, UnicodeDecodeError, ValueError) as error:
|
||||
unreadable.append(Unreadable(candidate.name, f"index.md unreadable: {error}"))
|
||||
continue
|
||||
if not bundle_id:
|
||||
unreadable.append(Unreadable(candidate.name, "index.md declares no bundle_id"))
|
||||
continue
|
||||
if bundle_id in bundles:
|
||||
unreadable.append(
|
||||
Unreadable(candidate.name, f"a second bundle claims the id `{bundle_id}`")
|
||||
)
|
||||
continue
|
||||
bundles[bundle_id] = Served(bundle_id, candidate)
|
||||
return Discovery(
|
||||
tuple(bundles[key] for key in sorted(bundles)),
|
||||
tuple(sorted(unreadable, key=lambda entry: entry.path)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Surface:
|
||||
"""What the two shapes have in common, with the difference in one flag."""
|
||||
|
||||
roots: tuple[Path, ...]
|
||||
fixed: str | None
|
||||
profile: BundleProfile
|
||||
|
||||
@property
|
||||
def one_to_many(self) -> bool:
|
||||
return self.fixed is None
|
||||
|
||||
def discovery(self) -> Discovery:
|
||||
"""Re-read on every call, in both shapes. The one-to-one server tests
|
||||
its own root; the one-to-many server tests what is under its roots."""
|
||||
return discover(self.roots, include_roots=not self.one_to_many)
|
||||
|
||||
def resolve(self, bundle_id: str | None) -> Served:
|
||||
"""The bundle a call names, or the fixed one. Never a guess.
|
||||
|
||||
A one-to-many call that names no bundle is a usage error and not a
|
||||
default: picking one would make the answer's provenance depend on
|
||||
directory order.
|
||||
"""
|
||||
found = self.discovery()
|
||||
served = {entry.bundle_id: entry for entry in found.bundles}
|
||||
if not self.one_to_many:
|
||||
assert self.fixed is not None
|
||||
if self.fixed not in served:
|
||||
raise ToolError(
|
||||
f"the bundle this server was started on is no longer readable: {self.fixed}",
|
||||
code="bundle_unreadable",
|
||||
)
|
||||
return served[self.fixed]
|
||||
if not bundle_id:
|
||||
raise ToolError(
|
||||
"this server serves several bundles; name one with `bundle_id` "
|
||||
f"({', '.join(sorted(served)) or 'none served'})",
|
||||
code="bundle_id_required",
|
||||
)
|
||||
if bundle_id in served:
|
||||
return served[bundle_id]
|
||||
for entry in found.unreadable:
|
||||
if entry.path == bundle_id:
|
||||
raise ToolError(
|
||||
f"`{bundle_id}` looks like a bundle and cannot be read as one: {entry.reason}",
|
||||
code="bundle_unreadable",
|
||||
)
|
||||
raise ToolError(
|
||||
f"no bundle named `{bundle_id}` is served "
|
||||
f"({', '.join(sorted(served)) or 'none served'})",
|
||||
code="bundle_unknown",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The card: everything about ONE bundle that a generic consumer needs
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def card(bundle_root: Path, *, profile: BundleProfile, concept_sample: int = 50) -> dict[str, Any]:
|
||||
"""The per-bundle numbers a generic reader needs, DERIVED on demand.
|
||||
|
||||
This is the half of a generated consumption skill that differs between
|
||||
bundles -- identity, concept count, which conditional fields are written on
|
||||
how many concepts, what the whole bundle costs. Today `okf skill` bakes
|
||||
those numbers into a document, which is what makes the document go stale
|
||||
when the bundle is rebuilt.
|
||||
|
||||
Derived rather than written into the bundle. 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 from the bundle in under a second. A stored
|
||||
card would also be one more artefact that can be stale, which is the defect
|
||||
it was meant to remove.
|
||||
"""
|
||||
from . import skill as okf_skill
|
||||
|
||||
bundle_id = okf_consume.root_bundle_id_of(bundle_root, profile=profile)
|
||||
concepts = okf_consume.link_parents(
|
||||
[
|
||||
okf_consume.read_concept(
|
||||
bundle_root / f"{concept_id}{profile.paths.concept_suffix}",
|
||||
bundle_root=bundle_root,
|
||||
root_bundle_id=bundle_id,
|
||||
)
|
||||
for concept_id in okf_consume.enumerate_concepts(bundle_root, profile=profile)
|
||||
]
|
||||
)
|
||||
counts = okf_skill.field_counts(concepts)
|
||||
return {
|
||||
"bundle_id": bundle_id,
|
||||
"ref": okf_consume.bundle_ref(bundle_root, profile=profile),
|
||||
"ref_algorithm": okf_consume.REF_ALGORITHM,
|
||||
"profile": okf_skill.PROFILE_NAME,
|
||||
"concept_count": len(concepts),
|
||||
"concepts": [concept.concept_id for concept in concepts[:concept_sample]],
|
||||
"concepts_truncated": len(concepts) > concept_sample,
|
||||
"source_files": sorted(
|
||||
{concept.source_file for concept in concepts if concept.source_file}
|
||||
),
|
||||
"conditional_fields": {
|
||||
field: counts.get(field, 0) for field in okf_skill.CONDITIONAL_FIELDS
|
||||
},
|
||||
"whole_bundle_bytes": okf_skill.whole_bundle_cost(concepts),
|
||||
"budget_unit": okf_consume.BUDGET_UNIT,
|
||||
"default_limit": okf_consume.DEFAULT_LIMIT,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tools. Each one has a reason, and the reason is the description a client reads.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tool:
|
||||
name: str
|
||||
description: str
|
||||
schema: dict[str, Any]
|
||||
|
||||
|
||||
_BUNDLE_ARGUMENT = {
|
||||
"bundle_id": {
|
||||
"type": "string",
|
||||
"description": "the bundle to act on; omit on a server started with --bundle",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def tools(surface: Surface) -> tuple[Tool, ...]:
|
||||
"""The minimum set that answers the questions a bundle exists to answer.
|
||||
|
||||
`okf_list` only on a server that serves more than one: on a one-to-one
|
||||
server there is nothing to list, and a tool that always returns the same
|
||||
single row invites a client to treat discovery as available when the
|
||||
deployment does not have it.
|
||||
"""
|
||||
bundle = _BUNDLE_ARGUMENT if surface.one_to_many else {}
|
||||
listing = (
|
||||
Tool(
|
||||
"okf_list",
|
||||
"Every OKF bundle this server can currently reach, with its content "
|
||||
"identity and concept count. Re-read from disk on every call, so a "
|
||||
"bundle added, removed or rebuilt since the last call is reflected "
|
||||
"without restarting anything. Exists because a client that cannot "
|
||||
"discover bundles must be told their names out of band, which is the "
|
||||
"configuration this shape is meant to remove.",
|
||||
{"type": "object", "properties": {}, "additionalProperties": False},
|
||||
),
|
||||
)
|
||||
common = (
|
||||
Tool(
|
||||
"okf_describe",
|
||||
"What one bundle is: its id, its content identity, how many concepts "
|
||||
"it holds, which source documents it was built from, and which "
|
||||
"conditionally-written fields are present on how many concepts. "
|
||||
"Exists because an answer must be attributable -- a claim from a "
|
||||
"bundle whose identity the caller cannot state is a claim with no "
|
||||
"provenance -- and because a reader needs the denominators before it "
|
||||
"can read an absence.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": dict(bundle),
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
"okf_ask",
|
||||
"One question, one bounded payload of excerpts, each carrying its "
|
||||
"bundle id, concept id, title and provenance locators, plus what was "
|
||||
"withheld and why. This is the library's only reading direction and "
|
||||
"it calls no model. On a multi-bundle server, omitting `bundle_id` "
|
||||
"asks every served bundle and splits the budget between them. Exists "
|
||||
"because handing a client the whole bundle is not an answer, and "
|
||||
"letting it choose files by name is the enumeration the consumption "
|
||||
"contract forbids.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question": {"type": "string", "description": "the question, in prose"},
|
||||
**bundle,
|
||||
"k": {
|
||||
"type": "integer",
|
||||
"description": f"how many concepts to consider (default {DEFAULT_K})",
|
||||
},
|
||||
"limit": {"type": "integer", "description": "payload budget in utf-8 bytes"},
|
||||
},
|
||||
"required": ["question"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
"okf_fetch",
|
||||
"One named concept, verbatim, with its frontmatter and its source "
|
||||
"locators. Exists because a ranked payload is a SELECTION: an arm "
|
||||
"that has been told a concept id -- by `okf_ask`, by a parent "
|
||||
"pointer, or by a citation it is checking -- needs the bytes "
|
||||
"themselves, and must not have to guess them from an excerpt.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"concept_id": {
|
||||
"type": "string",
|
||||
"description": "a bundle-relative concept id, as `okf_ask` reports it",
|
||||
},
|
||||
**bundle,
|
||||
},
|
||||
"required": ["concept_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
)
|
||||
return (listing + common) if surface.one_to_many else common
|
||||
|
||||
|
||||
def call_list(surface: Surface, _arguments: Mapping[str, Any]) -> dict[str, Any]:
|
||||
found = surface.discovery()
|
||||
entries: list[dict[str, Any]] = []
|
||||
for served in found.bundles:
|
||||
entries.append(
|
||||
{
|
||||
"bundle_id": served.bundle_id,
|
||||
"ref": okf_consume.bundle_ref(served.root, profile=surface.profile),
|
||||
"concept_count": len(
|
||||
okf_consume.enumerate_concepts(served.root, profile=surface.profile)
|
||||
),
|
||||
"directory": served.root.name,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"bundles": entries,
|
||||
"unreadable": [
|
||||
{"directory": entry.path, "reason": entry.reason} for entry in found.unreadable
|
||||
],
|
||||
"shape": "one-to-many" if surface.one_to_many else "one-to-one",
|
||||
}
|
||||
|
||||
|
||||
def call_describe(surface: Surface, arguments: Mapping[str, Any]) -> dict[str, Any]:
|
||||
served = surface.resolve(_string(arguments, "bundle_id"))
|
||||
return card(served.root, profile=surface.profile)
|
||||
|
||||
|
||||
def call_ask(surface: Surface, arguments: Mapping[str, Any]) -> dict[str, Any]:
|
||||
question = _string(arguments, "question")
|
||||
if not question:
|
||||
raise ToolError("`question` is required and may not be empty", code="question_missing")
|
||||
k = int(arguments.get("k") or DEFAULT_K)
|
||||
limit = int(arguments.get("limit") or okf_consume.DEFAULT_LIMIT)
|
||||
named = _string(arguments, "bundle_id")
|
||||
if named or not surface.one_to_many:
|
||||
targets = [surface.resolve(named)]
|
||||
else:
|
||||
targets = list(surface.discovery().bundles)
|
||||
if not targets:
|
||||
raise ToolError("no bundle is served under the given roots", code="bundle_none_served")
|
||||
share = max(1, limit // len(targets))
|
||||
if share < okf_consume.DEFAULT_LIMIT // 100:
|
||||
raise ToolError(
|
||||
f"the budget splits to {share} bytes across {len(targets)} bundles, which "
|
||||
"cannot carry an excerpt; name one bundle or raise `limit`",
|
||||
code="budget_too_thin",
|
||||
)
|
||||
answers = []
|
||||
for served in targets:
|
||||
try:
|
||||
payload = okf_consume.build_payload(
|
||||
served.root, question=question, k=k, limit=share, profile=surface.profile
|
||||
)
|
||||
except okf_consume.ConsumeError as error:
|
||||
raise ToolError(
|
||||
f"{served.bundle_id}: {error}", code=getattr(error, "code", "consume_refused")
|
||||
) from error
|
||||
answers.append({"bundle_id": served.bundle_id, "payload": payload})
|
||||
return {
|
||||
"question": question,
|
||||
"asked": [served.bundle_id for served in targets],
|
||||
"budget_per_bundle": share,
|
||||
"answers": answers,
|
||||
}
|
||||
|
||||
|
||||
def call_fetch(surface: Surface, arguments: Mapping[str, Any]) -> dict[str, Any]:
|
||||
concept_id = _string(arguments, "concept_id")
|
||||
if not concept_id:
|
||||
raise ToolError("`concept_id` is required", code="concept_id_missing")
|
||||
served = surface.resolve(_string(arguments, "bundle_id"))
|
||||
known = okf_consume.enumerate_concepts(served.root, profile=surface.profile)
|
||||
if concept_id not in known:
|
||||
raise ToolError(
|
||||
f"`{concept_id}` is not a concept the bundle's index names",
|
||||
code="concept_unknown",
|
||||
)
|
||||
suffix = surface.profile.paths.concept_suffix
|
||||
try:
|
||||
path = safe_resolve(served.root, f"{concept_id}{suffix}")
|
||||
except SourceError as error:
|
||||
raise ToolError(str(error), code="path_escape") from error
|
||||
size = path.stat().st_size
|
||||
if size > MAX_CONCEPT_BYTES:
|
||||
raise ToolError(
|
||||
f"`{concept_id}` is {size} bytes, above this server's {MAX_CONCEPT_BYTES}-byte "
|
||||
"ceiling for one concept; it is refused whole rather than truncated",
|
||||
code="concept_too_large",
|
||||
)
|
||||
concept = okf_consume.read_concept(
|
||||
path,
|
||||
bundle_root=served.root,
|
||||
root_bundle_id=okf_consume.root_bundle_id_of(served.root, profile=surface.profile),
|
||||
)
|
||||
return {
|
||||
"bundle_id": concept.bundle_id,
|
||||
"ref": okf_consume.bundle_ref(served.root, profile=surface.profile),
|
||||
"concept": {
|
||||
"concept_id": concept.concept_id,
|
||||
"title": concept.title,
|
||||
"sha256": concept.sha256,
|
||||
"adjudication": concept.adjudication,
|
||||
"req_number": concept.req_number,
|
||||
"source_file": concept.source_file,
|
||||
"sources": [dict(entry) for entry in concept.sources],
|
||||
"locators": dict(concept.locators),
|
||||
"text": concept.body,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _string(arguments: Mapping[str, Any], key: str) -> str:
|
||||
value = arguments.get(key)
|
||||
if value is None:
|
||||
return ""
|
||||
if not isinstance(value, str):
|
||||
raise ToolError(
|
||||
f"`{key}` must be a string, not {type(value).__name__}", code="argument_type"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
HANDLERS = {
|
||||
"okf_list": call_list,
|
||||
"okf_describe": call_describe,
|
||||
"okf_ask": call_ask,
|
||||
"okf_fetch": call_fetch,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The protocol: four methods, newline-delimited JSON-RPC 2.0 over stdio
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
METHOD_NOT_FOUND = -32601
|
||||
INVALID_PARAMS = -32602
|
||||
INTERNAL_ERROR = -32603
|
||||
|
||||
|
||||
def _tool_result(payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Both forms, on purpose.
|
||||
|
||||
`structuredContent` is what a client with a schema reads; the text block is
|
||||
what one without a schema reads, and a client that got only the first would
|
||||
see an empty message. The text is the SAME object, serialised -- two
|
||||
renderings of one answer, never two answers.
|
||||
"""
|
||||
text = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=False)
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"structuredContent": dict(payload),
|
||||
"isError": False,
|
||||
}
|
||||
|
||||
|
||||
def _tool_refusal(message: str, code: str) -> dict[str, Any]:
|
||||
return {
|
||||
"content": [{"type": "text", "text": f"refused ({code}): {message}"}],
|
||||
"isError": True,
|
||||
}
|
||||
|
||||
|
||||
def handle(surface: Surface, method: str, params: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""One request to one result. Raises `ToolError` only through the envelope."""
|
||||
if method == "initialize":
|
||||
return {
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"capabilities": {"tools": {"listChanged": False}},
|
||||
"serverInfo": {"name": SERVER_NAME, "version": _version()},
|
||||
"instructions": (
|
||||
"Bundles are read-only. Ask `okf_ask` a question in prose rather "
|
||||
"than fetching concepts by name: every excerpt it returns carries "
|
||||
"the bundle id and concept id a claim must be attributed to, and "
|
||||
"the payload states what it withheld and why."
|
||||
),
|
||||
}
|
||||
if method == "ping":
|
||||
return {}
|
||||
if method == "tools/list":
|
||||
return {
|
||||
"tools": [
|
||||
{"name": tool.name, "description": tool.description, "inputSchema": tool.schema}
|
||||
for tool in tools(surface)
|
||||
]
|
||||
}
|
||||
if method == "tools/call":
|
||||
name = params.get("name")
|
||||
arguments = params.get("arguments") or {}
|
||||
if not isinstance(arguments, Mapping):
|
||||
return _tool_refusal("`arguments` must be an object", "argument_type")
|
||||
available = {tool.name for tool in tools(surface)}
|
||||
if not isinstance(name, str) or name not in available:
|
||||
return _tool_refusal(
|
||||
f"no tool named {name!r} on this server ({', '.join(sorted(available))})",
|
||||
"tool_unknown",
|
||||
)
|
||||
try:
|
||||
return _tool_result(HANDLERS[name](surface, arguments))
|
||||
except ToolError as error:
|
||||
return _tool_refusal(str(error), error.code)
|
||||
except okf_consume.ConsumeError as error:
|
||||
return _tool_refusal(str(error), getattr(error, "code", "consume_refused"))
|
||||
except SourceError as error:
|
||||
return _tool_refusal(str(error), getattr(error, "code", "path_escape"))
|
||||
# Broad on purpose: a traceback on stdout would break the framing, and
|
||||
# a server that dies on one bad argument takes every other bundle with
|
||||
# it. The refusal is still loud, and it still carries a code.
|
||||
except Exception as error:
|
||||
return _tool_refusal(f"{type(error).__name__}: {error}", "tool_failed")
|
||||
raise LookupError(method)
|
||||
|
||||
|
||||
def _version() -> str:
|
||||
from . import __version__
|
||||
|
||||
return __version__
|
||||
|
||||
|
||||
def serve(surface: Surface, *, stdin: TextIO, stdout: TextIO) -> int:
|
||||
"""Read requests until stdin closes. One JSON object per line, both ways."""
|
||||
for line in stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue # unframeable input: there is no id to answer it under
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
method = str(message.get("method", ""))
|
||||
identifier = message.get("id")
|
||||
params = message.get("params") or {}
|
||||
if not isinstance(params, Mapping):
|
||||
params = {}
|
||||
if identifier is None:
|
||||
continue # a notification: acknowledged by doing nothing
|
||||
try:
|
||||
result: dict[str, Any] = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": identifier,
|
||||
"result": handle(surface, method, params),
|
||||
}
|
||||
except LookupError:
|
||||
result = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": identifier,
|
||||
"error": {"code": METHOD_NOT_FOUND, "message": f"no method {method!r}"},
|
||||
}
|
||||
except Exception as error:
|
||||
result = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": identifier,
|
||||
"error": {
|
||||
"code": INTERNAL_ERROR,
|
||||
"message": f"{type(error).__name__}: {error}",
|
||||
},
|
||||
}
|
||||
stdout.write(json.dumps(result, ensure_ascii=False) + "\n")
|
||||
stdout.flush()
|
||||
return 0
|
||||
|
||||
|
||||
def build_surface(
|
||||
*,
|
||||
bundle: Path | None,
|
||||
roots: Sequence[Path],
|
||||
profile: BundleProfile = okf_consume.DEFAULT_PROFILE,
|
||||
) -> Surface:
|
||||
if bundle is not None:
|
||||
index = bundle / "index.md"
|
||||
if not index.is_file():
|
||||
raise ToolError(
|
||||
f"{bundle} carries no index.md, so it is not a bundle", code="not_a_bundle"
|
||||
)
|
||||
bundle_id = _declared_bundle_id(index)
|
||||
if not bundle_id:
|
||||
raise ToolError(f"{index} declares no bundle_id", code="not_a_bundle")
|
||||
return Surface((bundle.resolve(),), bundle_id, profile)
|
||||
if not roots:
|
||||
raise ToolError("give either --bundle or at least one --root", code="no_target")
|
||||
return Surface(tuple(root.resolve() for root in roots), None, profile)
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str] | None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="okf mcp",
|
||||
description=(
|
||||
"Serve OKF bundles over the Model Context Protocol on stdio. "
|
||||
"`--bundle` serves one bundle and takes no bundle argument on its "
|
||||
"tools; `--root` serves every bundle found under the given "
|
||||
"directories and knows none of them by name."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--bundle", type=Path, help="serve exactly this bundle")
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
type=Path,
|
||||
action="append",
|
||||
default=[],
|
||||
help="serve every bundle under this directory (repeatable)",
|
||||
)
|
||||
return parser.parse_args(list(argv) if argv is not None else None)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
if args.bundle is not None and args.root:
|
||||
print("okf mcp: --bundle and --root are two shapes; give one", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
surface = build_surface(bundle=args.bundle, roots=args.root)
|
||||
except ToolError as error:
|
||||
print(f"okf mcp: refused ({error.code}): {error}", file=sys.stderr)
|
||||
return 2
|
||||
# Line-buffered both ways: a client blocks on our answer, and a block
|
||||
# buffer would hold it until the buffer filled or the process exited.
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(line_buffering=True)
|
||||
return serve(surface, stdin=sys.stdin, stdout=sys.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -54,6 +54,7 @@ from __future__ import annotations
|
|||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -179,6 +180,10 @@ TEMPLATE_ENUMERATION = (
|
|||
|
||||
TEMPLATE_OUTPUT = "Write to `<OUT>`. It must carry: the bundle ref; the findings, each with a"
|
||||
|
||||
#: Every per-corpus hole the template carries. A generic skill that left one
|
||||
#: would be the unfilled template with better manners, so it is refused.
|
||||
_PLACEHOLDER = re.compile(r"<[A-Z][A-Z_]*>")
|
||||
|
||||
REPLACED_BLOCKS = (
|
||||
TEMPLATE_HEADER,
|
||||
TEMPLATE_PRE_PASS,
|
||||
|
|
@ -664,7 +669,12 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||
parser = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
parser.add_argument("bundle", type=Path, help="the OKF bundle to instantiate a skill for")
|
||||
parser.add_argument(
|
||||
"bundle",
|
||||
type=Path,
|
||||
nargs="?",
|
||||
help="the OKF bundle to instantiate a skill for (unused with --generic)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out", type=Path, required=True, help="the skill directory to write (SKILL.md inside)"
|
||||
)
|
||||
|
|
@ -677,14 +687,30 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||
parser.add_argument(
|
||||
"--force", action="store_true", help="replace an existing SKILL.md at --out"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--generic",
|
||||
action="store_true",
|
||||
help=(
|
||||
"write the one-to-many skill instead: one installable document for ANY "
|
||||
"bundle, carrying no bundle's identity or numbers. `bundle` is then "
|
||||
"unused, and the reader is told to run `okf card <bundle>` at run time"
|
||||
),
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
written = generate(
|
||||
args.bundle, out=args.out, question=args.example_question, force=args.force
|
||||
if args.bundle is None and not args.generic:
|
||||
print("refused (bundle_missing): name a bundle, or pass --generic", file=sys.stderr)
|
||||
return 2
|
||||
written = (
|
||||
generate_generic(out=args.out, force=args.force)
|
||||
if args.generic
|
||||
else generate(
|
||||
args.bundle, out=args.out, question=args.example_question, force=args.force
|
||||
)
|
||||
)
|
||||
except okf_consume.ConsumeError as exc:
|
||||
print(f"refused ({exc.code}): {exc}")
|
||||
|
|
@ -701,3 +727,213 @@ def main(argv: list[str] | None = None) -> int:
|
|||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
||||
# --- The one-to-many candidate ------------------------------------------------
|
||||
|
||||
#: The name the generic skill carries. Claude Code takes a project skill's
|
||||
#: command from its DIRECTORY name and uses `name` only as a display label, so
|
||||
#: this is the label and not the command.
|
||||
GENERIC_NAME = "okf-consume-any"
|
||||
|
||||
#: The command that hands a reader the per-bundle numbers this skill does not
|
||||
#: carry. It has to exist for the skill to be honest: a generic document that
|
||||
#: told a reader to "check the denominators somewhere" would be the unfilled
|
||||
#: template with better manners.
|
||||
CARD_COMMAND = "okf card"
|
||||
|
||||
GENERIC_BUNDLE = "<the bundle you were pointed at>"
|
||||
|
||||
|
||||
def render_generic() -> str:
|
||||
"""One installable skill for ANY bundle, carrying no bundle's numbers.
|
||||
|
||||
The measured fact this answers: two skills generated for two different
|
||||
bundles are identical on 280 of 312 and 310 lines (measured 2026-09-20 on
|
||||
this machine, over `examples/ingest-golden-segmented-okf-v0-2` and
|
||||
`tests/fixtures/consume-bundle`; the order's own 227 of 285 is a different
|
||||
pair of bundles and neither number contradicts the other). The 30-odd lines
|
||||
that differ are identity, concept count, the conditional-field table, the
|
||||
whole-bundle cost and the breaking point -- all of them recomputable from
|
||||
the bundle in under a second, and all of them what makes a generated skill
|
||||
go stale the moment its bundle is rebuilt.
|
||||
|
||||
So this text carries NONE of them, and says where to read each one instead.
|
||||
The property that makes that claim checkable is that this function takes no
|
||||
argument: there is no bundle it could have read, and two calls return the
|
||||
same bytes.
|
||||
"""
|
||||
text = template_path().read_text(encoding="utf-8")
|
||||
text = text.split("---\n", 2)[2]
|
||||
replacements: list[tuple[str, str]] = [
|
||||
(
|
||||
TEMPLATE_HEADER,
|
||||
"**This file is generic: it carries no bundle's identity and no bundle's\n"
|
||||
"numbers,** and it is therefore never stale. It serves whichever bundle you\n"
|
||||
"are pointed at. Before answering, read that bundle's own card:\n\n"
|
||||
"```sh\n"
|
||||
f"{CARD_COMMAND} {GENERIC_BUNDLE}\n"
|
||||
"```\n\n"
|
||||
"The card is DERIVED from the bundle on every run, never stored in it, so\n"
|
||||
"there is no second artefact that can disagree with the bytes. Its\n"
|
||||
"`bundle_id` and `ref` are the identity to carry into your output; its\n"
|
||||
"`concept_count`, `conditional_fields` and `whole_bundle_bytes` are the\n"
|
||||
"denominators the sections below ask for. The section headings are fixed:\n"
|
||||
"the contract checker reads them by name.",
|
||||
),
|
||||
(
|
||||
TEMPLATE_PRE_PASS,
|
||||
"```sh\n"
|
||||
f"{PRE_PASS_COMMAND} \\\n"
|
||||
f" {GENERIC_BUNDLE} \\\n"
|
||||
' --question "your question" \\\n'
|
||||
" --ref THE_REF \\\n"
|
||||
" --out /tmp/payload.json\n"
|
||||
"```\n\n"
|
||||
"`--ref` is an **assertion**, never an override: the identity is computed\n"
|
||||
"from the bytes either way, and a mismatch refuses. Read the pre-pass's\n"
|
||||
"own exit status, which carries three values: **0** a payload was written,\n"
|
||||
"**1** the run happened and refused, **2** the run did not happen at all.",
|
||||
),
|
||||
(
|
||||
TEMPLATE_CHECK,
|
||||
f"```sh\n{CHECKER_COMMAND} --skill <this file> --payload /tmp/payload.json\n```",
|
||||
),
|
||||
(
|
||||
TEMPLATE_CONTRACT_LINE,
|
||||
f"The contract this skill is held to is `{CONTRACT}`. Where this",
|
||||
),
|
||||
(
|
||||
TEMPLATE_EXTENSIONS,
|
||||
"**Extensions.** This skill declares none. A corpus needing one declares it\n"
|
||||
"in its own documentation; the five markings below are never extended here,\n"
|
||||
"because a marking invented for one bundle would travel to every other.",
|
||||
),
|
||||
(
|
||||
TEMPLATE_CONDITIONAL,
|
||||
"**Conditionally-written fields.** Read `conditional_fields` from the card:\n"
|
||||
"it gives, per field, how many of the bundle's concepts carry it. A field\n"
|
||||
"written on some concepts and not others means its ABSENCE on one concept\n"
|
||||
"is a measurement about that concept, never a fact about the world — so\n"
|
||||
"report the count beside any claim that rests on an absence. The fields\n"
|
||||
f"this profile can write are: {', '.join(f'`{field}`' for field in CONDITIONAL_FIELDS)}.",
|
||||
),
|
||||
(
|
||||
TEMPLATE_SCALING,
|
||||
"**Scaling.** Cost tracks the QUESTION, not the corpus: the payload is cut\n"
|
||||
f"to {okf_consume.DEFAULT_LIMIT} {okf_consume.BUDGET_UNIT} whatever the bundle's size. What\n"
|
||||
"does track the corpus is the bookkeeping — one `withheld` entry per\n"
|
||||
"considered-and-not-delivered concept — so the point at which this strategy\n"
|
||||
"stops fitting is a property of the bundle. Read `whole_bundle_bytes` from\n"
|
||||
"the card and compare it with the budget: a bundle costing less than the\n"
|
||||
"budget could have been handed over whole, and the pre-pass is then a\n"
|
||||
"convenience rather than a necessity.",
|
||||
),
|
||||
(
|
||||
TEMPLATE_DENOMINATORS,
|
||||
"The payload reports three counts — `considered`, `withheld`, `delivered` —\n"
|
||||
"and `considered == withheld + delivered`. Carry them into your output, and\n"
|
||||
"carry the card's `concept_count` beside them: `considered` is what the cut\n"
|
||||
"looked at, and the card says how much of the bundle that was.",
|
||||
),
|
||||
(
|
||||
TEMPLATE_ENUMERATION,
|
||||
f"- **No directory enumeration** unless the profile (`{PROFILE_NAME}`) says the\n"
|
||||
" index is derived. The payload's own `bundle.entries_match_directory` says\n"
|
||||
" whether it does, for the bundle in front of you.",
|
||||
),
|
||||
(
|
||||
TEMPLATE_OUTPUT,
|
||||
"Write to the path the caller names, or to your answer if none was named.\n"
|
||||
"It must carry: the bundle ref; the findings, each with a",
|
||||
),
|
||||
("`<CORPUS>` bundle", "bundle you were pointed at"),
|
||||
("# <CORPUS> consumption", "# OKF bundle consumption"),
|
||||
]
|
||||
# The blocks are STRICT: a template that stopped carrying one has drifted,
|
||||
# and rewriting the rest would ship a skill missing a whole section.
|
||||
for old, new in replacements:
|
||||
if old not in text:
|
||||
raise SkillError(
|
||||
f"the template no longer carries the block this generator rewrites: {old[:70]!r}",
|
||||
code="template_drift",
|
||||
)
|
||||
text = text.replace(old, new)
|
||||
# The tokens are LENIENT, and the sweep below is what makes that safe: a
|
||||
# token may already have been consumed by the block that carried it, and a
|
||||
# strict check here would only measure the order of this list.
|
||||
for old, new in (
|
||||
("<PROFILE_NAME>", PROFILE_NAME),
|
||||
("<PRE_PASS_COMMAND>", PRE_PASS_COMMAND),
|
||||
("<BUDGET_LIMIT>", str(okf_consume.DEFAULT_LIMIT)),
|
||||
("<BUDGET_UNIT>", okf_consume.BUDGET_UNIT),
|
||||
("<BUDGET_INSTRUMENT>", okf_consume.BUDGET_INSTRUMENT),
|
||||
("<KNOWN_POSITIVE_CASE>", okf_consume.KNOWN_POSITIVE_CASE),
|
||||
("<KNOWN_POSITIVE_EXPECTED>", str(okf_consume.KNOWN_POSITIVE_EXPECTED)),
|
||||
("<BUNDLE_ROOT>", GENERIC_BUNDLE),
|
||||
("<PAYLOAD_PATH>", "/tmp/payload.json"),
|
||||
("<SKILL_PATH>", "this file"),
|
||||
("<REF>", "the card's `ref`"),
|
||||
("<OUT>", "the path the caller named"),
|
||||
):
|
||||
text = text.replace(old, new)
|
||||
left = sorted(set(_PLACEHOLDER.findall(text)))
|
||||
if left:
|
||||
raise SkillError(
|
||||
f"the generic skill still carries a per-corpus hole: {', '.join(left)}. A hole "
|
||||
"left in a generic document is a number the reader is invited to invent",
|
||||
code="placeholder_unfilled",
|
||||
)
|
||||
description = block_scalar(
|
||||
"Answer one question about ANY OKF bundle from a bounded payload assembled "
|
||||
"by a deterministic pre-pass, marking every claim with its source, its title "
|
||||
"and its provenance locator. Carries no bundle's identity: read the bundle's "
|
||||
f"own card with `{CARD_COMMAND}` first. Use when the user asks a question of, "
|
||||
"or states a hypothesis about, a corpus held as an OKF bundle."
|
||||
)
|
||||
header = f"---\nname: {block_scalar(GENERIC_NAME)}\ndescription: {description}\n---\n"
|
||||
return header + text
|
||||
|
||||
|
||||
def generate_generic(*, out: Path, force: bool = False) -> Path:
|
||||
"""Write the generic skill. Takes no bundle, by construction."""
|
||||
target = out / "SKILL.md"
|
||||
if target.exists() and not force:
|
||||
raise SkillError(
|
||||
f"{target} already exists; pass --force to replace it",
|
||||
code="target_occupied",
|
||||
)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(render_generic(), encoding="utf-8")
|
||||
return target
|
||||
|
||||
|
||||
def card_main(argv: list[str] | None = None) -> int:
|
||||
"""`okf card <bundle>` -- the per-bundle half of a consumption skill, as JSON.
|
||||
|
||||
The generic skill above tells its reader to run this. It is DERIVED on every
|
||||
run and never stored in the bundle: 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.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="okf card",
|
||||
description=(
|
||||
"Print one bundle's identity, concept count, conditional-field counts "
|
||||
"and whole-bundle cost as JSON. Derived from the bundle on every run."
|
||||
),
|
||||
)
|
||||
parser.add_argument("bundle", type=Path, help="the OKF bundle to describe")
|
||||
args = parser.parse_args(argv)
|
||||
from .mcp_server import card as build_card
|
||||
|
||||
try:
|
||||
payload = build_card(args.bundle.resolve(), profile=okf_consume.DEFAULT_PROFILE)
|
||||
except okf_consume.ConsumeError as exc:
|
||||
print(f"refused ({exc.code}): {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except OSError as exc:
|
||||
print(f"the run did not happen: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ def test_every_pinned_denominator_is_recounted_here() -> None:
|
|||
)
|
||||
assert gate.DISCOVERY_BUNDLES * len(gate.DISCOVERY_CHECKS) == 3 * 3
|
||||
assert len(gate.CROSS_CHECKS) == 3
|
||||
assert gate.HOSTILE_CASES == (
|
||||
assert tuple(gate.HOSTILE_CASES) == (
|
||||
"traversal-in-bundle-id",
|
||||
"traversal-in-concept-id",
|
||||
"symlink-out-of-root",
|
||||
|
|
@ -75,6 +75,9 @@ def test_every_pinned_denominator_is_recounted_here() -> None:
|
|||
"oversized-concept",
|
||||
"unknown-bundle-id",
|
||||
)
|
||||
# The 10 MB case has exactly one acceptable refusal. Anything else means
|
||||
# the ceiling did not run -- the false green this row shipped with once.
|
||||
assert gate.HOSTILE_CASES["oversized-concept"] == frozenset({"concept_too_large"})
|
||||
assert gate.OVERSIZED_BYTES == 10 * 1024 * 1024
|
||||
|
||||
|
||||
|
|
@ -128,7 +131,7 @@ def test_a_question_set_whose_bytes_moved_is_a_usage_error_not_a_red_row(
|
|||
payload = {
|
||||
"sporsmal": [
|
||||
{
|
||||
"bundle": "bridge-notes",
|
||||
"bundles": ["bridge-notes"],
|
||||
"atomer": [{"kilde_anker": "spennvidde", "kilde_sitat": "24 meter"}],
|
||||
}
|
||||
]
|
||||
|
|
@ -171,6 +174,15 @@ def test_an_older_freeze_version_is_refused_rather_than_measured(scratch: Path)
|
|||
gate.read_anchor_set(questions, freeze, want_version=4)
|
||||
|
||||
|
||||
def test_a_refusal_code_is_read_from_the_message_a_client_sees() -> None:
|
||||
"""Driven from both sides: a coded refusal yields its code, an uncoded one
|
||||
yields the empty string rather than a plausible guess."""
|
||||
assert gate.refusal_code(gate.RpcError(-32000, "refused (concept_too_large): 10 MB")) == (
|
||||
"concept_too_large"
|
||||
)
|
||||
assert gate.refusal_code(gate.RpcError(-32000, "something went wrong")) == ""
|
||||
|
||||
|
||||
def test_the_gate_reads_bundle_and_concept_ids_at_any_depth() -> None:
|
||||
"""An `ask` answer carries one id per excerpt and a `list` answer one per
|
||||
bundle. A rule reading the top level only would score the shape."""
|
||||
|
|
@ -222,3 +234,64 @@ def test_one_documented_sequence_answers_from_two_bundles(scratch: Path) -> None
|
|||
def test_hostile_input_is_refused_out_loud(scratch: Path) -> None:
|
||||
row = _row(6, scratch)
|
||||
assert row.k == row.m == 6, f"{row.k} of {row.m}: {row.details}"
|
||||
|
||||
|
||||
def test_row_twos_denominator_is_the_sets_and_not_what_the_surface_reached(
|
||||
scratch: Path,
|
||||
) -> None:
|
||||
"""The defect this repository has already met twice: a row that counts
|
||||
against what the run produced closes by producing less.
|
||||
|
||||
Three pairs are declared and one of them names an anchor no bundle carries,
|
||||
so a denominator taken from the run would read `2 of 2` and call the gate
|
||||
satisfied. Driven from both sides -- the reachable pairs really are
|
||||
reachable, so a `0 of 3` would be a different defect.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
bundles = gate.corpus(scratch / "base")
|
||||
questions = scratch / "sporsmal.json"
|
||||
questions.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"sporsmal": [
|
||||
{
|
||||
"bundles": ["bridge-notes"],
|
||||
"atomer": [
|
||||
{"kilde_anker": "spennvidde", "kilde_sitat": "spennvidde 24 meter"},
|
||||
{"kilde_anker": "rekkverk", "kilde_sitat": "1,2 meter hoeyt"},
|
||||
{
|
||||
"kilde_anker": "finnes-ikke",
|
||||
"kilde_sitat": "dette staar ingen steder",
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
freeze = scratch / "frys.json"
|
||||
freeze.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"versjon": 4,
|
||||
"sha256": {"sporsmal.json": hashlib.sha256(questions.read_bytes()).hexdigest()},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
anchors = gate.read_anchor_set(questions, freeze, want_version=4)
|
||||
assert len(anchors.pairs) == 3
|
||||
rows = {
|
||||
row.number: row
|
||||
for row in gate.evaluate(
|
||||
scratch / "run",
|
||||
anchors=anchors,
|
||||
real={"bridge-notes": bundles["bridge-notes"]},
|
||||
)
|
||||
}
|
||||
row = rows[2]
|
||||
assert row.m == 3, f"the denominator came from the run: {row.k} of {row.m}"
|
||||
assert row.k == 2, f"{row.k} of {row.m}: {row.details}"
|
||||
assert row.status == gate.RED
|
||||
|
|
|
|||
196
tests/test_mcp_server.py
Normal file
196
tests/test_mcp_server.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"""The MCP surface itself (`src/llm_ingestion_okf/mcp_server.py`).
|
||||
|
||||
`tools/okf_mcp_gate.py` measures this module over a real protocol and is the
|
||||
eval it was built against. These tests hold the pieces the gate reaches only
|
||||
through a verdict: what discovery does with a symlink, what the card does NOT
|
||||
do to the bundle, and the property that makes the generic skill's whole claim
|
||||
checkable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from llm_ingestion_okf import mcp_server, skill
|
||||
|
||||
TOOLS = Path(__file__).resolve().parents[1] / "tools"
|
||||
sys.path.insert(0, str(TOOLS))
|
||||
|
||||
import okf_mcp_gate as gate # noqa: E402
|
||||
|
||||
GOLDEN = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "examples"
|
||||
/ "ingest-golden-segmented-okf-v0-2"
|
||||
/ "expected-bundle"
|
||||
)
|
||||
|
||||
|
||||
def _tree(root: Path) -> dict[str, str]:
|
||||
return {
|
||||
str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
for path in sorted(root.rglob("*"))
|
||||
if path.is_file()
|
||||
}
|
||||
|
||||
|
||||
def test_the_card_is_derived_and_writes_nothing_into_the_bundle(tmp_path: Path) -> None:
|
||||
"""The whole reason the card is not a file in the bundle.
|
||||
|
||||
A stored card would move the bytes of all six `examples/*/expected-bundle`
|
||||
trees and of the pinned reference bundle, and would be one more artefact
|
||||
that can disagree with what is beside it.
|
||||
"""
|
||||
bundle = tmp_path / "b"
|
||||
bundle.mkdir()
|
||||
for source in GOLDEN.rglob("*"):
|
||||
target = bundle / source.relative_to(GOLDEN)
|
||||
if source.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
target.write_bytes(source.read_bytes())
|
||||
before = _tree(bundle)
|
||||
first = mcp_server.card(bundle, profile=mcp_server.okf_consume.DEFAULT_PROFILE)
|
||||
second = mcp_server.card(bundle, profile=mcp_server.okf_consume.DEFAULT_PROFILE)
|
||||
assert _tree(bundle) == before
|
||||
assert first == second
|
||||
assert first["concept_count"] == 3
|
||||
assert first["ref"].startswith("sha256-tree:")
|
||||
|
||||
|
||||
def test_the_card_command_prints_json_a_reader_can_parse() -> None:
|
||||
"""The generic skill tells its reader to run this. A command whose output
|
||||
could not be read back would make that instruction decorative."""
|
||||
run = subprocess.run(
|
||||
[sys.executable, "-m", "llm_ingestion_okf.cli", "card", str(GOLDEN)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert run.returncode == 0, run.stderr
|
||||
payload = json.loads(run.stdout)
|
||||
assert payload["bundle_id"] == "b-golden-segmented-okf-v0-2"
|
||||
assert set(payload) >= {"ref", "concept_count", "conditional_fields", "whole_bundle_bytes"}
|
||||
|
||||
|
||||
def test_discovery_never_descends_a_symlink(tmp_path: Path) -> None:
|
||||
"""Driven from both sides: the real directory IS found, the link to it is
|
||||
not. Without the control, a rule that found nothing at all would pass."""
|
||||
outside = tmp_path / "outside"
|
||||
gate.write_bundle(outside / "secret", "secret-notes", [("x", "X", "y")])
|
||||
root = tmp_path / "root"
|
||||
gate.write_bundle(root / "real", "real-notes", [("x", "X", "y")])
|
||||
(root / "linked").symlink_to(outside / "secret", target_is_directory=True)
|
||||
|
||||
found = mcp_server.discover([root])
|
||||
assert [served.bundle_id for served in found.bundles] == ["real-notes"]
|
||||
assert mcp_server.discover([outside]).bundles[0].bundle_id == "secret-notes"
|
||||
|
||||
|
||||
def test_a_directory_that_cannot_be_read_as_a_bundle_is_reported_not_skipped(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""An absence with no denominator is not a boundary. A broken manifest that
|
||||
simply vanished from the list would be indistinguishable from a bundle that
|
||||
was never there."""
|
||||
root = tmp_path / "root"
|
||||
gate.write_bundle(root / "good", "good-notes", [("x", "X", "y")])
|
||||
gate.write_bundle(root / "bad", "bad-notes", [("x", "X", "y")])
|
||||
(root / "bad" / "index.md").write_bytes(b"\xff\xfe\x00")
|
||||
found = mcp_server.discover([root])
|
||||
assert [served.bundle_id for served in found.bundles] == ["good-notes"]
|
||||
assert [entry.path for entry in found.unreadable] == ["bad"]
|
||||
|
||||
|
||||
def test_the_one_to_one_server_finds_the_bundle_it_was_started_on(tmp_path: Path) -> None:
|
||||
"""The defect the gate found on this module's first build: `--bundle` points
|
||||
AT a bundle, and discovery that only looked at children found none."""
|
||||
gate.write_bundle(tmp_path / "b", "solo-notes", [("x", "X", "y")])
|
||||
surface = mcp_server.build_surface(bundle=tmp_path / "b", roots=[])
|
||||
assert surface.resolve(None).bundle_id == "solo-notes"
|
||||
|
||||
|
||||
def test_a_one_to_many_call_naming_no_bundle_is_refused_rather_than_guessed(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Picking one would make an answer's provenance depend on directory order."""
|
||||
root = tmp_path / "root"
|
||||
gate.write_bundle(root / "a", "a-notes", [("x", "X", "y")])
|
||||
gate.write_bundle(root / "b", "b-notes", [("x", "X", "y")])
|
||||
surface = mcp_server.build_surface(bundle=None, roots=[root])
|
||||
with pytest.raises(mcp_server.ToolError) as raised:
|
||||
surface.resolve(None)
|
||||
assert raised.value.code == "bundle_id_required"
|
||||
|
||||
|
||||
def test_the_one_to_one_server_offers_no_listing_tool(tmp_path: Path) -> None:
|
||||
"""A tool that always returns the same single row invites a client to treat
|
||||
discovery as available where the deployment does not have it."""
|
||||
gate.write_bundle(tmp_path / "b", "solo-notes", [("x", "X", "y")])
|
||||
one = mcp_server.build_surface(bundle=tmp_path / "b", roots=[])
|
||||
many = mcp_server.build_surface(bundle=None, roots=[tmp_path])
|
||||
assert [tool.name for tool in mcp_server.tools(one)] == list(gate.REQUIRED_TOOLS["one-to-one"])
|
||||
assert [tool.name for tool in mcp_server.tools(many)] == list(
|
||||
gate.REQUIRED_TOOLS["one-to-many"]
|
||||
)
|
||||
|
||||
|
||||
def test_every_tool_carries_a_written_reason() -> None:
|
||||
"""The order's rule: a tool with no reason written down is a tool nobody
|
||||
has to justify keeping."""
|
||||
surface = mcp_server.Surface(
|
||||
(Path("/nonexistent"),), None, mcp_server.okf_consume.DEFAULT_PROFILE
|
||||
)
|
||||
for tool in mcp_server.tools(surface):
|
||||
assert "Exists because" in tool.description, tool.name
|
||||
assert len(tool.description) > 200, tool.name
|
||||
|
||||
|
||||
# --- the one-to-many skill candidate ------------------------------------------
|
||||
|
||||
|
||||
def test_the_generic_skill_carries_no_bundles_identity(tmp_path: Path) -> None:
|
||||
"""The property that makes the candidate's whole claim checkable: it takes
|
||||
no argument, so there is no bundle it could have read.
|
||||
|
||||
Controlled against a per-bundle skill, which must carry exactly what this
|
||||
one does not -- without that control a test asserting an absence would pass
|
||||
on an empty string.
|
||||
"""
|
||||
generic = skill.render_generic()
|
||||
skill.generate(GOLDEN, out=tmp_path / "per-bundle", question="krav", force=True)
|
||||
per_bundle = (tmp_path / "per-bundle" / "SKILL.md").read_text(encoding="utf-8")
|
||||
|
||||
bundle_id = "b-golden-segmented-okf-v0-2"
|
||||
ref = mcp_server.okf_consume.bundle_ref(GOLDEN)
|
||||
assert bundle_id in per_bundle and ref in per_bundle
|
||||
assert bundle_id not in generic and ref not in generic
|
||||
assert skill.CARD_COMMAND in generic
|
||||
|
||||
|
||||
def test_the_generic_skill_is_the_same_bytes_for_anyone(tmp_path: Path) -> None:
|
||||
"""Two calls, and a written file, all identical. A rebuild of any bundle
|
||||
cannot make this artefact wrong, which is stronger than refusing loudly."""
|
||||
first = skill.render_generic()
|
||||
written = skill.generate_generic(out=tmp_path / "g")
|
||||
assert written.read_text(encoding="utf-8") == first == skill.render_generic()
|
||||
|
||||
|
||||
def test_the_generic_skill_leaves_no_per_corpus_hole() -> None:
|
||||
"""A hole left in a generic document is a number the reader is invited to
|
||||
invent -- which is the unfilled template's own defect."""
|
||||
assert skill._PLACEHOLDER.findall(skill.render_generic()) == []
|
||||
|
||||
|
||||
def test_the_generic_skill_keeps_every_section_the_checker_reads() -> None:
|
||||
from llm_ingestion_okf import contract_check
|
||||
|
||||
text = skill.render_generic()
|
||||
for section in contract_check.REQUIRED_SECTIONS:
|
||||
assert f"## {section}" in text, section
|
||||
|
|
@ -42,6 +42,7 @@ import argparse
|
|||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
|
@ -104,14 +105,20 @@ CROSS_CHECKS: tuple[str, ...] = (
|
|||
"documented-sequence",
|
||||
)
|
||||
|
||||
HOSTILE_CASES: tuple[str, ...] = (
|
||||
"traversal-in-bundle-id",
|
||||
"traversal-in-concept-id",
|
||||
"symlink-out-of-root",
|
||||
"broken-manifest",
|
||||
"oversized-concept",
|
||||
"unknown-bundle-id",
|
||||
)
|
||||
#: Each hostile case with the refusal CODES that count as the right refusal.
|
||||
#: A code set rather than a bare "was refused": the first run of this gate had
|
||||
#: the 10 MB concept refused as `concept_unknown`, because the fixture wrote
|
||||
#: the file without naming it in the index -- the size ceiling never ran, and
|
||||
#: the row was green for a reason that had nothing to do with the attack. Two
|
||||
#: checks giving the same verdict are not the same guarantee.
|
||||
HOSTILE_CASES: Mapping[str, frozenset[str]] = {
|
||||
"traversal-in-bundle-id": frozenset({"bundle_unknown", "path_escape", "bundle_id_invalid"}),
|
||||
"traversal-in-concept-id": frozenset({"concept_unknown", "path_escape"}),
|
||||
"symlink-out-of-root": frozenset({"bundle_unknown", "path_escape"}),
|
||||
"broken-manifest": frozenset({"bundle_unreadable"}),
|
||||
"oversized-concept": frozenset({"concept_too_large"}),
|
||||
"unknown-bundle-id": frozenset({"bundle_unknown"}),
|
||||
}
|
||||
|
||||
# A concept large enough that reading it whole is a decision rather than an
|
||||
# accident. The order names 10 MB; the gate writes exactly that.
|
||||
|
|
@ -492,6 +499,20 @@ def _source_ids(answer: Mapping[str, Any]) -> set[str]:
|
|||
return found
|
||||
|
||||
|
||||
_REFUSAL_CODE = re.compile(r"refused \(([a-z_]+)\)")
|
||||
|
||||
|
||||
def refusal_code(error: RpcError) -> str:
|
||||
"""The code a refusal carries, or `""`.
|
||||
|
||||
Read from the message because that is where a client sees it: a tool-level
|
||||
refusal travels in the result envelope, and its JSON-RPC number is the same
|
||||
for every one of them.
|
||||
"""
|
||||
match = _REFUSAL_CODE.search(error.message)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def _probe_arguments(tool: str, bundle_id: str, concept_id: str, *, named: bool) -> dict[str, Any]:
|
||||
"""One representative call per tool. `named` is False for the one-to-one
|
||||
variant, whose bundle is fixed at startup and takes no bundle argument."""
|
||||
|
|
@ -615,22 +636,24 @@ def read_anchor_set(questions: Path, freeze: Path, *, want_version: int) -> Anch
|
|||
document = json.loads(questions.read_text(encoding="utf-8"))
|
||||
pairs: dict[tuple[str, str], str] = {}
|
||||
for question in document.get("sporsmal", []):
|
||||
bundle = str(question.get("bundle") or question.get("kilde") or "")
|
||||
# The set names its bundles as a LIST per question, and a question may
|
||||
# name several. A pair is (bundle, anchor), so a question naming two
|
||||
# bundles and one anchor is two pairs: an anchor reachable in one
|
||||
# bundle and not the other is two different facts.
|
||||
named = [str(entry) for entry in (question.get("bundles") or []) if entry]
|
||||
for atom in question.get("atomer", []) or []:
|
||||
anchor = atom.get("kilde_anker")
|
||||
quote = atom.get("kilde_sitat")
|
||||
quote = str(atom.get("kilde_sitat") or "")
|
||||
if isinstance(anchor, str) and anchor:
|
||||
pairs.setdefault((bundle, anchor), str(quote or ""))
|
||||
for bundle in named:
|
||||
if pairs.get((bundle, anchor), "") == "":
|
||||
pairs[(bundle, anchor)] = quote
|
||||
for cite in question.get("must_cite", []) or []:
|
||||
if isinstance(cite, str) and cite:
|
||||
pairs.setdefault((bundle, cite), "")
|
||||
elif isinstance(cite, Mapping):
|
||||
anchor = cite.get("anker") or cite.get("kilde_anker")
|
||||
if isinstance(anchor, str) and anchor:
|
||||
pairs.setdefault(
|
||||
(str(cite.get("bundle") or bundle), anchor),
|
||||
str(cite.get("kilde_sitat") or ""),
|
||||
)
|
||||
entries = cite if isinstance(cite, list) else [cite]
|
||||
for entry in entries:
|
||||
if isinstance(entry, str) and entry:
|
||||
for bundle in named:
|
||||
pairs.setdefault((bundle, entry), "")
|
||||
return AnchorSet(
|
||||
label=f"{questions.parent.name}/{questions.name}",
|
||||
pairs=tuple((bundle, anchor, quote) for (bundle, anchor), quote in sorted(pairs.items())),
|
||||
|
|
@ -651,12 +674,50 @@ SYNTHETIC_ANCHORS: tuple[tuple[str, str, str], ...] = (
|
|||
)
|
||||
|
||||
|
||||
def _fetch_carries(client: Stdio, bundle_id: str, anchor: str, quote: str, *, named: bool) -> bool:
|
||||
"""Can the surface hand back the text carrying `quote`, verbatim?
|
||||
_WHITESPACE = re.compile(r"\s+")
|
||||
|
||||
This is a CEILING, never a hit rate: it asks whether the bytes are
|
||||
reachable at all, not whether a ranker would choose them.
|
||||
|
||||
def fold(text: str) -> str:
|
||||
"""The one normalisation both sides of a quote comparison get.
|
||||
|
||||
Two removals and nothing else. U+00AD, because `okf build` strips soft
|
||||
hyphens from extracted text (`extract.normalise_extracted`) while the
|
||||
publisher's own JSON keeps them, so a quote carrying one could never match
|
||||
text that is otherwise identical. And whitespace runs, because a quote cut
|
||||
out of a paragraph carries the line breaks of wherever it was cut. Case is
|
||||
NOT folded and no character is transliterated: a quote is a quote.
|
||||
"""
|
||||
return _WHITESPACE.sub(" ", text.replace("\u00ad", "")).strip()
|
||||
|
||||
|
||||
def _reach_anchor(client: Stdio, bundle_id: str, anchor: str, quote: str) -> tuple[bool, str]:
|
||||
"""A documented two-step sequence for one anchor, with the route recorded.
|
||||
|
||||
Step one asks whether the anchor IS a concept id -- the cheap case, and a
|
||||
true ceiling. Step two asks the surface the anchor as a question and reads
|
||||
the delivered excerpts. Step two goes through the ranker, so a pair met
|
||||
only there is a FLOOR on the ceiling and never the ceiling itself: the row
|
||||
prints both counts rather than one.
|
||||
"""
|
||||
needle = fold(quote)
|
||||
try:
|
||||
answer = client.call("okf_fetch", {BUNDLE_KEY: bundle_id, "concept_id": anchor})
|
||||
except (RpcError, ServerGone):
|
||||
pass
|
||||
else:
|
||||
if not needle or needle in fold(json.dumps(answer, ensure_ascii=False)):
|
||||
return True, "fetch"
|
||||
if not needle:
|
||||
return False, "no quote to look for"
|
||||
try:
|
||||
answer = client.call("okf_ask", {BUNDLE_KEY: bundle_id, "question": anchor})
|
||||
except (RpcError, ServerGone) as error:
|
||||
return False, f"ask refused: {error}"
|
||||
return (needle in fold(json.dumps(answer, ensure_ascii=False))), "ask"
|
||||
|
||||
|
||||
def _fetch_carries(client: Stdio, bundle_id: str, anchor: str, quote: str, *, named: bool) -> bool:
|
||||
"""The synthetic known-positive's route: fetch by id, nothing else."""
|
||||
arguments: dict[str, Any] = {"concept_id": anchor}
|
||||
if named:
|
||||
arguments[BUNDLE_KEY] = bundle_id
|
||||
|
|
@ -668,8 +729,30 @@ def _fetch_carries(client: Stdio, bundle_id: str, anchor: str, quote: str, *, na
|
|||
return quote in text if quote else bool(_source_ids(answer))
|
||||
|
||||
|
||||
def present_in_bundle(bundle_root: Path, quotes: Sequence[str]) -> list[bool]:
|
||||
"""Is the quote anywhere in the bundle's concept bodies?
|
||||
|
||||
Read with the LIBRARY rather than the surface, on purpose: this separates
|
||||
"the bundle does not carry it" from "the surface could not reach it", and
|
||||
the order asks for the first to be reported as a fact about the bundle
|
||||
rather than a defect in the server.
|
||||
"""
|
||||
from llm_ingestion_okf import consume as okf_consume
|
||||
|
||||
haystack = fold(
|
||||
"\n".join(
|
||||
(bundle_root / f"{concept_id}.md").read_text(encoding="utf-8", errors="replace")
|
||||
for concept_id in okf_consume.enumerate_concepts(bundle_root)
|
||||
)
|
||||
)
|
||||
return [bool(quote) and fold(quote) in haystack for quote in quotes]
|
||||
|
||||
|
||||
def row_two(
|
||||
bundles: Mapping[str, Path], reachable: tuple[bool, str], anchors: AnchorSet | None
|
||||
bundles: Mapping[str, Path],
|
||||
reachable: tuple[bool, str],
|
||||
anchors: AnchorSet | None,
|
||||
real: Mapping[str, Path] | None = None,
|
||||
) -> Row:
|
||||
details: list[str] = []
|
||||
known_positive = 0
|
||||
|
|
@ -687,10 +770,11 @@ def row_two(
|
|||
f"known-positive (synthetic, this file's own text): "
|
||||
f"{known_positive} of {len(SYNTHETIC_ANCHORS)} anchors fetched verbatim"
|
||||
)
|
||||
name = "coverage ceiling: every anchor the frozen set points at, fetched verbatim"
|
||||
if anchors is None:
|
||||
return _row(
|
||||
2,
|
||||
"coverage ceiling: every anchor the frozen set points at, fetched verbatim",
|
||||
name,
|
||||
0,
|
||||
0,
|
||||
"no frozen question set supplied (--sett/--frys); the denominator is "
|
||||
|
|
@ -699,42 +783,85 @@ def row_two(
|
|||
)
|
||||
m = len(anchors.pairs)
|
||||
details.append(
|
||||
f"set {anchors.label}, freeze version {anchors.version}, M = {m} (bundle, anchor) pairs"
|
||||
f"set {anchors.label}, freeze version {anchors.version}, "
|
||||
f"M = {m} (bundle, anchor) pairs counted from the file"
|
||||
)
|
||||
if not reachable[0]:
|
||||
if not real:
|
||||
return _row(
|
||||
2,
|
||||
"coverage ceiling: every anchor the frozen set points at, fetched verbatim",
|
||||
name,
|
||||
0,
|
||||
m,
|
||||
f"`python -c 'import {SERVER_MODULE}'` fails: {reachable[1]}",
|
||||
"no bundle root supplied (--bundle-root); the set's bundles are not "
|
||||
"committed here and the row cannot be measured without them",
|
||||
details,
|
||||
)
|
||||
if not reachable[0]:
|
||||
return _row(2, name, 0, m, f"server missing: {reachable[1]}", details)
|
||||
|
||||
by_bundle: dict[str, list[tuple[str, str]]] = {}
|
||||
for bundle_id, anchor, quote in anchors.pairs:
|
||||
by_bundle.setdefault(bundle_id, []).append((anchor, quote))
|
||||
|
||||
root = next(iter(real.values())).parent
|
||||
k = 0
|
||||
missing_bundles: set[str] = set()
|
||||
root = next(iter(bundles.values())).parent
|
||||
carried = 0
|
||||
routes = {"fetch": 0, "ask": 0}
|
||||
with server(variant_argv("one-to-many", root)) as client:
|
||||
client.handshake()
|
||||
served = {str(entry) for entry in _bundle_ids(client.call("okf_list", {}))}
|
||||
for bundle_id, anchor, quote in anchors.pairs:
|
||||
if bundle_id not in served:
|
||||
missing_bundles.add(bundle_id)
|
||||
for bundle_id in sorted(by_bundle):
|
||||
entries = by_bundle[bundle_id]
|
||||
target = real.get(bundle_id)
|
||||
if target is None:
|
||||
details.append(
|
||||
f"{bundle_id}: RED for the BUNDLE -- no root supplied holds it "
|
||||
f"({len(entries)} of {m} pairs)"
|
||||
)
|
||||
continue
|
||||
if _fetch_carries(client, bundle_id, anchor, quote, named=True):
|
||||
k += 1
|
||||
if missing_bundles:
|
||||
details.append(
|
||||
"red for the BUNDLE, not the server: no bundle served under this root is "
|
||||
f"named {', '.join(sorted(missing_bundles))}"
|
||||
)
|
||||
return _row(
|
||||
2,
|
||||
"coverage ceiling: every anchor the frozen set points at, fetched verbatim",
|
||||
k,
|
||||
m,
|
||||
"the ceiling an arm can reach, per (bundle, anchor) pair",
|
||||
details,
|
||||
served_id = _served_name(target, served)
|
||||
if served_id is None:
|
||||
details.append(
|
||||
f"{bundle_id}: RED for the BUNDLE -- {target.name} is not served "
|
||||
f"({len(entries)} of {m} pairs)"
|
||||
)
|
||||
continue
|
||||
here = present_in_bundle(target, [quote for _anchor, quote in entries])
|
||||
carried += sum(here)
|
||||
hit = 0
|
||||
for (anchor, quote), _in_bundle in zip(entries, here, strict=True):
|
||||
met, route = _reach_anchor(client, served_id, anchor, quote)
|
||||
if met:
|
||||
hit += 1
|
||||
routes[route] = routes.get(route, 0) + 1
|
||||
k += hit
|
||||
details.append(
|
||||
f"{bundle_id} (served as `{served_id}`): {hit} of {len(entries)} reached; "
|
||||
f"{sum(here)} of {len(entries)} present in the bundle at all"
|
||||
)
|
||||
details.append(
|
||||
f"routes: {routes.get('fetch', 0)} met by `okf_fetch` on the anchor as a concept id "
|
||||
f"(a true ceiling), {routes.get('ask', 0)} met only through `okf_ask` (a FLOOR on the "
|
||||
"ceiling: that route runs the ranker)"
|
||||
)
|
||||
details.append(
|
||||
f"present in some bundle at all: {carried} of {m} -- a pair the bundle does not "
|
||||
"carry is red for the bundle, not for the server"
|
||||
)
|
||||
return _row(2, name, k, m, "the ceiling an arm can reach, per (bundle, anchor) pair", details)
|
||||
|
||||
|
||||
def _served_name(target: Path, served: set[str]) -> str | None:
|
||||
"""The id the server calls this directory, read from the bundle itself.
|
||||
|
||||
Never guessed from the directory name: the frozen set names `n100-2023`
|
||||
and the bundle declares `vegnormal-n100-2023`, so a rule matching names
|
||||
would have to invent an alias rule of its own.
|
||||
"""
|
||||
from llm_ingestion_okf import consume as okf_consume
|
||||
|
||||
declared = okf_consume.root_bundle_id_of(target)
|
||||
return declared if declared in served else None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
|
@ -1031,6 +1158,14 @@ def row_six(scratch: Path) -> Row:
|
|||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Named in the index, or it is not a concept and the ceiling never runs:
|
||||
# the bundle's own index is what makes a file reachable at all.
|
||||
index = root / "bridge-notes" / "index.md"
|
||||
index.write_text(
|
||||
index.read_text(encoding="utf-8").rstrip("\n")
|
||||
+ "\n- [Svulmende](svulmende.md) \u2014 adjudication: proposed\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
details: list[str] = []
|
||||
k = 0
|
||||
|
|
@ -1053,8 +1188,16 @@ def row_six(scratch: Path) -> Row:
|
|||
try:
|
||||
answer = client.call(tool, arguments)
|
||||
except RpcError as error:
|
||||
k += 1
|
||||
details.append(f"{case}: refused loudly ({error})")
|
||||
code = refusal_code(error)
|
||||
if code in HOSTILE_CASES[case]:
|
||||
k += 1
|
||||
details.append(f"{case}: refused loudly ({code})")
|
||||
else:
|
||||
details.append(
|
||||
f"{case}: refused as `{code or error.code}`, which is not the "
|
||||
f"check this case attacks ({'/'.join(sorted(HOSTILE_CASES[case]))}) "
|
||||
f"-- {error}"
|
||||
)
|
||||
continue
|
||||
except ServerGone as error:
|
||||
details.append(f"{case}: the server died instead of refusing ({error})")
|
||||
|
|
@ -1099,12 +1242,13 @@ def evaluate(
|
|||
scratch: Path,
|
||||
*,
|
||||
anchors: AnchorSet | None = None,
|
||||
real: Mapping[str, Path] | None = None,
|
||||
) -> list[Row]:
|
||||
reachable = server_exists()
|
||||
bundles = corpus(scratch / "base")
|
||||
return [
|
||||
row_one(bundles, reachable),
|
||||
row_two(bundles, reachable, anchors),
|
||||
row_two(bundles, reachable, anchors, real),
|
||||
row_three(scratch),
|
||||
row_four(scratch),
|
||||
row_five(scratch),
|
||||
|
|
@ -1131,6 +1275,35 @@ def render(rows: Sequence[Row]) -> str:
|
|||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _real_bundles(roots: Sequence[Path], anchors: AnchorSet | None) -> dict[str, Path]:
|
||||
"""Map every bundle the set names to a directory under the given roots.
|
||||
|
||||
The match is on the SET's name against the directory name's leading
|
||||
segment, and the served id is then read from the bundle itself. A set name
|
||||
matching two directories is a usage error: choosing one would make the
|
||||
measurement depend on directory order.
|
||||
"""
|
||||
if not roots or anchors is None:
|
||||
return {}
|
||||
wanted = sorted({bundle for bundle, _anchor, _quote in anchors.pairs})
|
||||
found: dict[str, Path] = {}
|
||||
for name in wanted:
|
||||
hits = [
|
||||
child
|
||||
for root in roots
|
||||
for child in sorted(root.iterdir())
|
||||
if child.is_dir() and child.name.startswith(name)
|
||||
]
|
||||
if len(hits) > 1:
|
||||
raise GateUsage(
|
||||
f"`{name}` matches {len(hits)} directories under the given roots: "
|
||||
f"{', '.join(hit.name for hit in hits)}"
|
||||
)
|
||||
if hits:
|
||||
found[name] = hits[0]
|
||||
return found
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
|
||||
parser.add_argument("--json", action="store_true", help="emit the rows as JSON")
|
||||
|
|
@ -1144,6 +1317,17 @@ def main(argv: list[str] | None = None) -> int:
|
|||
type=Path,
|
||||
help="the freeze file that pins --sett by sha256 and declares its version",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bundle-root",
|
||||
type=Path,
|
||||
action="append",
|
||||
default=[],
|
||||
help=(
|
||||
"a directory holding the set's bundles, for row 2 (repeatable). The "
|
||||
"bundles are never committed here; the row maps a set name to a served "
|
||||
"bundle by reading the bundle's own declared id"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sett-versjon",
|
||||
type=int,
|
||||
|
|
@ -1159,8 +1343,9 @@ def main(argv: list[str] | None = None) -> int:
|
|||
)
|
||||
if (args.sett is None) != (args.frys is None):
|
||||
raise GateUsage("--sett and --frys are given together or not at all")
|
||||
real = _real_bundles(args.bundle_root, anchors)
|
||||
with tempfile.TemporaryDirectory(prefix="okf-mcp-gate-") as scratch:
|
||||
rows = evaluate(Path(scratch), anchors=anchors)
|
||||
rows = evaluate(Path(scratch), anchors=anchors, real=real)
|
||||
except GateUsage as error:
|
||||
print(f"okf-mcp-gate: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue