okf-mcp 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,426 @@
1
+ ---
2
+ type: Capability
3
+ title: MCP server (okf-mcp)
4
+ description: The kernel's proven capabilities projected onto the Model Context Protocol — fourteen read-only tools, concepts as resources, and the two consuming prompts, for any MCP-capable agent host.
5
+ resource: gems/okf-mcp/lib/okf/mcp/server.rb
6
+ tags: [mcp, serve, agent, registry, search]
7
+ generated:
8
+ by: human:maintainer
9
+ at: 2026-08-14T12:00:00Z
10
+ ---
11
+
12
+
13
+ The tools this argues for are catalogued in [tools](/capabilities/tools.md);
14
+ what implements them is [the server definition](/structure/server-definition.md).
15
+
16
+ # Overview
17
+
18
+ `okf-mcp` is the **fourth surface** beside the CLI (`@okf cli`), the
19
+ graph server (`@okf capabilities/graph-server`) and the library API (`@okf capabilities/library-api`): a
20
+ sibling gem that maps MCP tool calls onto the kernel's library so any
21
+ MCP-capable host — Claude Desktop, Claude Code, anything speaking the protocol
22
+ — can discover, orient in, search, and read the bundles on a machine. It is
23
+ judged by one rule: it inherits the existing contracts or it is drift. Nothing
24
+ is reimplemented — every tool is a library call, and logic a tool needs that
25
+ the kernel lacks lands in the kernel first, where the other three surfaces get
26
+ it too.
27
+
28
+ The `status`/`trust` filters keep one rule across surfaces by *where they
29
+ narrow*: on `catalog` they run through the kernel's `Bundle::RowFilter` like
30
+ every row filter, but on `search` they resolve **through the catalog** — a
31
+ search row carries what the engine matched on, and the §5 families are not
32
+ among it, so handing them to the row filter would read as absent and match
33
+ nothing. The tool instead asks the catalog which ids qualify, per bundle, and
34
+ keeps the rows that survive: the same predicate underneath, so a `trust` that
35
+ narrows `catalog` narrows `search` identically. The first cut was the
36
+ two-line fix — add the keys to the filter — and it shipped the worse failure:
37
+ a schema that accepts the argument and an answer that silently matches
38
+ nothing.
39
+
40
+ The `tags`/`types`/`stats` trio closes the read-view gap the parity audit
41
+ priced, on the kernel-first path: `Bundle#tag_groups` and `Bundle#stats`
42
+ were extracted from the CLI verbs so the counting rules have one home, and
43
+ both shells consume them. `files` is deliberately not a tool — `index`'s
44
+ per-directory listing and `catalog`'s projection already carry its whole
45
+ answer, and it would have been the first tool whose answer two others hold
46
+ whole; tool-list weight is a cost a host pays on every conversation.
47
+
48
+ The `references` tool is the §6.3 inventory the kernel's verb answers —
49
+ notably the one lens that sees a bundle's *non-markdown* files (a `.py`
50
+ attester, a `.sql` computation), with each file's citing concepts and every
51
+ pointer into `references/` that resolves to nothing, the bare-path miss named
52
+ with its leading-slash fix. It landed here the way every capability does:
53
+ kernel first (`Bundle::References`), then a thin projection.
54
+
55
+ # Identity is the kernel registry's
56
+
57
+ Every tool takes a `bundle` argument that is a **registry slug** — the same
58
+ identity `@slug` resolves at the CLI and `/b/<slug>/` mounts on the
59
+ hub (`@okf capabilities/bundles-manager`). One name across all four surfaces, and a slug is
60
+ only ever a key into the served map: no tool opens a path from a request.
61
+
62
+ **One name across the fourteen tools, too**, which took a second pass. `search` is
63
+ the only one that accepts a *set*, and it announced that in the argument's name
64
+ — `bundles` against nine `bundle`s. The plural was a signal nobody could act
65
+ on: an MCP host's unknown property is refused by the schema before any okf
66
+ sentence can be written, so a caller that had just used `dirs(bundle:)` got
67
+ back "object property at `/bundle` is a disallowed additional property" and no
68
+ hint. The type already declares that it takes an array, and the
69
+ CLI (`@okf cli`) spells the identity slot identically for every verb —
70
+ `okf search <dir|@slug…>` beside `okf lint <dir|@slug>`. Renamed before the
71
+ first release, on the same rule the `exe/okf-mcp` deletion followed: **a name
72
+ in the public surface is a compatibility promise from the moment it ships**,
73
+ and an alias would have been the second spelling that deletion refused. It also
74
+ retired a guard rather than adding one — a test existed only to stop the
75
+ near-miss from silently widening a search to every bundle, and the asymmetry
76
+ that made that possible is gone. Argv
77
+ roots are the allowlist (`okf mcp <dir> @slug …`), slugged by the
78
+ registry's (`@okf registry`) own normalization with registered slugs reserved
79
+ before basenames are deduped; no argv serves the active registry — the
80
+ project-local discovery, `$OKF_HOME` fallback and `OKF_NO_DISCOVERY` lever
81
+ included, for free, by loading through it. Groups fan out for `search` and are
82
+ refused by every single-bundle tool (the second-bundle rule by another
83
+ spelling); `"*"` tolerates a vanished directory and names the skip in the
84
+ payload, while naming one bundle demands it exist.
85
+
86
+ **The allowlist is closed at boot, and that is a property of the object, not a
87
+ promise in a comment.** Resolving an `@ref` consults the kernel registry — and
88
+ the first version then *kept* it, which quietly made a request-time door out of
89
+ a boot-time convenience: a group slug handed to `search` expanded through the
90
+ retained registry and returned bodies from bundles the operator had
91
+ deliberately not served. The fix is structural rather than a check: argv mode
92
+ does not carry the registry into the instance at all, so there is nothing to
93
+ expand through. Groups are a registry-mode identity, and in argv mode they have
94
+ already fanned out to their leaves by the time any tool runs. The general
95
+ shape, worth carrying to the next surface: **a capability held "just to answer
96
+ questions" is reachable by anything that can ask one.**
97
+
98
+ # The long-lived holder's branch
99
+
100
+ The search (`@okf capabilities/search`) capability's lifecycle asymmetry, honored from the
101
+ other side: a one-shot CLI gets the scan, a long-lived holder gets the
102
+ prepared corpus. okf-mcp holds one parsed bundle per root, re-read only when
103
+ the on-disk fingerprint moves — bodies are always live and canonical — and one
104
+ shared `Search.prepare` corpus per searched set for index queries, built on
105
+ first use and **dropped when any member's fingerprint moves** (a held index
106
+ outliving its set is a wrong answer, not a slow one — the hub's contract).
107
+
108
+ The **identity map obeys the same rule**, which it did not at first. Served by
109
+ the registry (`@okf registry`), the set of bundles was a boot snapshot — as the
110
+ hub's (`@okf capabilities/bundles-manager`) *mounted* set still is, so this was consistency
111
+ rather than oversight — and three of the four ways it went stale were loud: an
112
+ unknown slug names what it knows. The fourth was not. An entry repointed at a
113
+ new directory kept answering from the old one under the current slug, which is
114
+ the silent wrong answer this project refuses everywhere else. The fix needed no
115
+ new mechanism, only the one already here: `stat` the registry file, re-read on
116
+ a moved fingerprint. Argv mode is untouched and not by a flag — it never
117
+ carried the kernel registry, so there is nothing to re-read and no way to
118
+ widen. A file that cannot be read or parsed keeps the last good set *without*
119
+ latching its stamp, so a transient truncation is survived rather than made
120
+ permanent until restart.
121
+
122
+ **The stamp watches every file the registry reads, not just its own.** okf's
123
+ registry can `link` another registry file, and that file's bundles resolve into
124
+ the served set — so a stamp over one path meant an `okf registry set` in the
125
+ linked file was never seen, and this server kept answering about the set it
126
+ booted with. That is the same silent wrong answer the rule above exists to
127
+ refuse, arriving through a second door. The link list comes from the kernel, so
128
+ adding or dropping a link moves the first path's stamp and the next pass watches
129
+ the new set; nothing has to be registered here.
130
+
131
+ The two kinds of file are watched under **different rules**, and the asymmetry is
132
+ the whole of it. This server's own registry going unreadable answers `nil`, which
133
+ holds the last good set — the paragraph above. A linked file is a pointer's
134
+ target, and okf already treats a missing one as *resolves to nothing, reported*;
135
+ following that here means a vanished target drops its bundles rather than
136
+ freezing them. Riding out an error and following a state change are different
137
+ jobs, and one stamp had to do both.
138
+ <!-- rule:okf-mcp-stamp-watches-links -->
139
+
140
+ Four consequences of that rule had to be chased down after it landed. Two are
141
+ the same mistake in different clothes — **something else was still a boot
142
+ snapshot** — and two are what a *movable* served set costs a cache that was
143
+ written for a fixed one. The residency held one parsed bundle per root and never
144
+ evicted, which was bounded only by the set being fixed at boot; it is pruned to
145
+ the served set whenever the set *moves* now — not on every request, which took
146
+ the residency and corpus locks and queued every unrelated call behind whatever
147
+ index build another host was paying for — since an operator repointing entries
148
+ otherwise retains every root the registry has ever named — and the prune
149
+ reaches the corpus cache too, which pins the same parsed bundles plus a
150
+ prepared index over each: its LRU evicts only on an index query, so a
151
+ scan-only workload would have held a dropped root's corpus forever. And the on-disk
152
+ fingerprint is taken once per root per request rather than per ask: freshness is
153
+ a question *between* requests, and asking it three times inside one cost three
154
+ full-tree walks under the residency lock (`search` on the index engine measured
155
+ three, now one). Both hang off the request seam, which is the two public
156
+ entry points and deliberately not `handle_request` — the JSON-RPC layer treats
157
+ the handler block as a lookup and calls what it returns afterwards, so a wrapper
158
+ there encloses nothing. The resource list was computed once and handed to the SDK as a
159
+ fixed array, so a bundle registered afterwards was never advertised and a
160
+ removed one stayed advertised until a read of the very URI we published came
161
+ back "unknown bundle" — it is derived per `resources/list` now, at the one
162
+ `stat` per bundle it always cost. And the *stamp itself* was taken after the
163
+ boot read rather than before it, so a write landing in between was recorded as
164
+ already-seen: entries from before it, fingerprint from after, and nothing to do
165
+ until some further write moved the fingerprint again. Booting stamps nothing
166
+ now; the first call re-reads. A stamp is a claim about a file you have already
167
+ read, and it may never be taken later than the read it labels.
168
+ Federation runs through `Search.across`/one corpus, so BM25 scores are
169
+ comparable by construction. The engine doctrine is the CLI's exactly: scan by
170
+ default, `engine: "index"` opt-in, `fuzzy` implying the index, `regexp` on the
171
+ scan, incompatible pairs refused naming the fix.
172
+
173
+ # Bounded outputs, honest errors
174
+
175
+ Every list output carries a visible `total` — `search` caps at 20 rows,
176
+ `catalog` pages at 200, rollups cap at 25 with `other_*` remainders, `index`
177
+ descends one level unless asked, and `graph` serves three views that never
178
+ carry bodies (the dump anti-pattern stays unreachable).
179
+
180
+ **A `total` bounds nothing unless it counts the thing that grows.** `log`
181
+ carried one from the start and was the one unbounded read on the surface: it
182
+ counted log *files*, so `total: 1` sat above this repo's entire 119,863-byte
183
+ history — the answer to "what changed recently" scaling with the project's age
184
+ rather than the question. The pre-release ROI eval found it, and on the same
185
+ recommended path the instructions name. It now returns the newest three
186
+ date-grouped entries per file (§9's own structure) with each file's `total` and
187
+ `returned`, which cut that answer to 13,491 bytes and a whole eval session in
188
+ half. The split stays here rather than in the kernel because bounding for a
189
+ context window is this surface's problem alone — the graph server's Log panel (`@okf capabilities/graph-server`) wants the whole file and scrolls it. The
190
+ general shape: **a bound that counts containers instead of contents reads as
191
+ bounded and is not**, which is the same false-comfort class as a capability
192
+ declared by default.
193
+
194
+ That shape kept producing instances, each found by review rather than by use.
195
+ §9 fixes no heading level, so a log grouped under `###` is conformant and the
196
+ `## ` split cannot see it: the file came back *whole* under `total: 0`, an
197
+ unbounded read advertising itself as empty, and `limit` could not reach the
198
+ path at all. The first fix counted it as one indivisible entry cut by size —
199
+ and left the same read alive one shape over, where a whole history under a
200
+ single `## ` heading split into one "entry" and came back whole behind a
201
+ `total: 1` that read as bounded. The byte budget `limit` scales now caps
202
+ *every* answer, announced with `truncated` — inventing a boundary would be
203
+ inventing a format, but declaring a bound is only honest. Both of the answer's
204
+ units keep their word: the budget is enforced in **bytes** as announced (a
205
+ character count let a multibyte log through at up to 4x the cap, silently),
206
+ and `returned` is recounted from what *survived* the cut, never claiming an
207
+ entry whose heading the cut removed. A scaffolded title with no entries yet
208
+ reports the zero it holds instead of one entry of history that does not exist
209
+ — wherever whitespace put the title. And `total` itself meant two things: the rows
210
+ matched in `catalog`/`search`, the whole bundle's directory count in
211
+ `dirs`/`index`. Each defensible alone; as a set it made the larger number read
212
+ as rows withheld from a tool that takes no limit. One key, one question.
213
+
214
+ **An empty answer that reads like a real one is the same failure wearing
215
+ zero.** `dirs` and `index` refused a `dir` naming no directory; `catalog` and
216
+ `search` answered `total: 0` to it. Worst for `root` — the spelling the
217
+ CLI (`@okf cli`) and the skill (`@okf capabilities/agent-skill`) both teach — where an agent
218
+ asked for the bundle root, was told zero, and reported that the bundle root
219
+ holds nothing. Every tool taking a `dir` refuses one now, and across bundles the
220
+ refusal is a fact about the searched *set*: a directory one of three bundles has
221
+ still filters, because otherwise the ordinary cross-bundle ask would break.
222
+ The set consulted is `Bundle#directories` — the same list the `dirs` rows are
223
+ built from — because the refusal's own advice is "orient with dirs", and a
224
+ first cut that derived it from the raw file list accepted directories `dirs`
225
+ refuses to list (one holding only a file the reader skipped). One question,
226
+ one source, on both sides of the refusal. The message carries the one nuance
227
+ the source cannot: a directory standing on disk but holding only unparseable
228
+ files is refused as exactly that — "holds only files the reader could not
229
+ parse", pointing at `validate` — because "no directory" would be false about
230
+ the filesystem and sends the caller off to re-spell a name that was correct
231
+ when the fix is repairing the files.
232
+
233
+ Kernel refusals become
234
+ `isError` tool responses carrying the kernel's own sentences; the tool
235
+ descriptions carry the skill's retrieval doctrine (orient with `dirs`, descend
236
+ with `index`, search for pointed questions, read only winners), because they
237
+ are the only playbook a Desktop host ever sees. Two prompts — the consuming
238
+ pair — restate that doctrine in full, in tool vocabulary; why only two is the
239
+ section below.
240
+
241
+ # One entry point: the `okf mcp` verb
242
+
243
+ The gem is separate because it has to be: the `mcp` SDK's floor is 2.7 against
244
+ the kernel's 2.4, and it brings five transitive dependencies to a tool whose
245
+ runtime set is deliberately three. Neither fact argues for a separate
246
+ *command*, and conflating the two questions is what left `okf mcp` unbuilt at
247
+ 0.1.0. The extension seam (`@okf-eco design/extension-points`) exists precisely so
248
+ the dependency stays on the addon's side of the line: `okf-mcp/lib/okf/plugin.rb`
249
+ registers the verb, the baseline names nothing, and a 2.4 machine simply cannot
250
+ install the gem (`required_ruby_version` refuses). The kernel floor is the same
251
+ kind of promise and easier to break silently: the gemspec must name the okf
252
+ release that ships every API the shell calls (`>= 1.13`, for
253
+ `Bundle#directories`), because the monorepo's path pin satisfies any floor and
254
+ hides one that lies — the declared minimum admitted a kernel the code raised
255
+ NoMethodError against, and no test can notice without installing the old gem.
256
+ So the verb is free, and what
257
+ it buys is discoverability — the server appears in `okf help` under *installed
258
+ extensions* on the machines that have it, rather than waiting to be known about.
259
+
260
+ Once the verb existed, the `exe/okf-mcp` beside it was a second name for the
261
+ same `CLI.run` — one more thing to install, document, spell correctly in a host
262
+ config and keep working. It went, before the first release, while removing a
263
+ name still cost nobody anything; after one it would have been a break for every
264
+ config that spelled it. The generalizable half is the timing, not the deletion:
265
+ **an entry point is a compatibility promise from the moment it ships**, so the
266
+ window for having second thoughts closes at the first release, not at the first
267
+ complaint.
268
+
269
+ Three obligations come with routing a protocol server through a CLI dispatcher.
270
+ **Stdout stays pure**: the kernel's dispatch path writes plugin diagnostics and
271
+ unknown-verb refusals to stderr, never stdout, and `MCP::CLI` now takes the
272
+ human channel as a parameter instead of writing to `$stdout` — so the verb
273
+ honors the injected streams and no boot line can corrupt the first frame. A
274
+ spawned test asserts the first byte on stdout is a JSON-RPC frame. **The SDK
275
+ loads inside `#call`**, not at the top of the plugin file, because discovery
276
+ requires that file for `okf help` and for every unknown verb; a `require` at
277
+ load time would charge every one of those runs for a server nobody asked for.
278
+ **The exit contract is structural.** Boot and serve are separate phases in the
279
+ verb itself: everything that can fail as an operator mistake — argv, the
280
+ registry, the HTTP bind — happens under a rescue that answers exit 2 with one
281
+ line, and nothing raised while serving can reach it. On stdio the two hang-up
282
+ errnos are the session's normal end, exit 0; any other mid-serve errno
283
+ propagates as the crash it is. Diagnostics are best-effort throughout, because
284
+ a dead stderr must not decide a server's fate: before the split, the boot
285
+ rescue's own print re-raised EPIPE as a backtrace for a normal hang-up, and a
286
+ lost `--http` boot line read as a clean exit 0 for a server that never started
287
+ accepting.
288
+
289
+ # What the protocol offers that tools do not
290
+
291
+ Ten tools mapped the CLI's read verbs and stopped there, which left most of MCP
292
+ unused. Three additions came from comparing the surface against the protocol
293
+ rather than against the CLI.
294
+
295
+ **Resources** are the one affordance a tool call cannot provide: a host can
296
+ *attach* a document to the context itself, without the model deciding to fetch
297
+ it. Every bundle with a root `index.md` is `okf://<slug>`; every concept is
298
+ covered by the template `okf://{bundle}/{id}`. Concepts are deliberately *not*
299
+ enumerated — that would read every bundle at boot, the eager work the residency
300
+ layer exists to avoid, and would freeze a list the fingerprint check keeps
301
+ honest. The template is signage only: the SDK binds a variable to `[^/]+` and
302
+ every OKF id below the root carries a slash, so the parsing is ours. The
303
+ allowlist holds on this surface too — a URI is not a path, and a slug in one is
304
+ still only a key into the served map.
305
+
306
+ **Completions** make the template browsable instead of a shape you must already
307
+ know, and they are where containment is easiest to lose: an unserved bundle, an
308
+ unknown argument and a missing context all complete to nothing, so no
309
+ completion can confirm what argv did not serve.
310
+
311
+ **Structured output** ends the blob: every JSON tool declares its shape and
312
+ emits `structuredContent` beside the text. Declaring a shape is also what
313
+ exposes what the shape leaves out — `search` named its query, its bundles and
314
+ its rows, and not the **engine that answered**, which `fuzzy` selects without
315
+ being asked. Nothing in the result recovers it: the scan's integer count and
316
+ the index's BM25 float both round to a number. So a miss under the index's
317
+ tokenizer (`@okf capabilities/search`) — a shattered identifier, a documented recall hole —
318
+ was indistinguishable from a fact the bundle does not hold, which is the
319
+ silent wrong answer again in its quietest form. The schemas are proven rather than
320
+ asserted — the suite runs every tool through every variant with the SDK's
321
+ result validation switched on, while production leaves it off, because a schema
322
+ bug should fail a test rather than turn a working tool into a runtime error.
323
+
324
+ The honesty rule the `readOnlyHint` annotations already followed turned out to
325
+ be broken at the handshake: passing no `capabilities:` inherited the SDK's
326
+ default, which announced `resources` while `resources/list` answered `[]`,
327
+ `logging` that nothing emitted through, and `listChanged` on lists nothing
328
+ notifies about. Declaring them explicitly is the fix, and the general shape is
329
+ the same one the containment hole taught: **a default you did not choose is
330
+ still a claim you made.**
331
+
332
+ `listChanged` stays undeclared even now that the resource list genuinely moves,
333
+ because the test is whether anything *notifies*, not whether anything changes.
334
+ A host that re-lists sees the current set; one that caches the boot listing is
335
+ stale until it asks again. Declaring the capability is what would fix that, and
336
+ it waits on something actually sending the notification — announcing it first
337
+ would only invite a host to wait for one that never comes.
338
+
339
+ # The prompts are the consuming pair, in tool vocabulary
340
+
341
+ The prompt surface took three cuts to find its principle. Four of the
342
+ skill's (`@okf capabilities/agent-skill`) nine playbooks shipped first, selected by nothing
343
+ better than their names resembling tools. The second cut served all eight
344
+ (everything but `doctor`, whose premise — install the CLI — anything reaching
345
+ this server has disproved), on the argument that **a prompt is instructions,
346
+ not a capability**: the writing a playbook describes is done by the host's own
347
+ tools, so the read-only posture excluded nothing.
348
+
349
+ That argument is true and was still the wrong test, because it never asked
350
+ *whose* instructions they were. Every playbook speaks in `okf …` invocations,
351
+ half dead-end a CLI-less host at "install the CLI first" (`menu`'s step 1
352
+ literally ends "Everything below needs the CLI"), six link on into a reference
353
+ tree this server does not serve — and five teach authoring, a mission every
354
+ tool here refuses. To the host this surface exists for, they taught a
355
+ vocabulary it cannot use toward work it cannot do. The right test is the
356
+ mission: this server makes a client an expert **consumer** of bundles, so it
357
+ serves the two consuming playbooks — `okf-search` and `okf-consume`, in
358
+ `SKILL.md`'s own order — rewritten against the tools (`list_bundles`, `dirs`,
359
+ `index`, `search`, `read_concept`, `log`, `graph`), with the engine doctrine
360
+ and the anti-patterns carried over in the tools' argument spellings. A test
361
+ pins the voice: every tool the texts name must exist on the wire, and no
362
+ backticked CLI invocation survives.
363
+
364
+ The rewrite has a real cost, accepted knowingly: the texts are okf-mcp's own
365
+ (`lib/okf/mcp/prompts/`), no longer read from the installed kernel, so a
366
+ doctrine change in the skill must be carried here by hand. What it bought is
367
+ that the prompts work where they are served — and it dissolved the version-skew
368
+ failure mode the old path carried, where a kernel that renamed a playbook left
369
+ this server advertising a file that is gone. Authoring stays with the skill,
370
+ installed where a filesystem and the CLI actually are.
371
+
372
+ # Transports and posture
373
+
374
+ One server definition, two transports: stdio (default — each host spawns its
375
+ own process, boot line on stderr) and `--http` — Streamable HTTP in stateless
376
+ JSON mode on the WEBrick the kernel already ships. A third hosting, not a
377
+ third transport: `OKF::MCP.app` hands the same definition and stateless
378
+ transport to any Rack server a `config.ru` names — the server is the
379
+ reader's dependency, never this gem's, so the no-rackup position holds while
380
+ puma and its kin stop being closed doors. The seam inherits this section's
381
+ whole posture verbatim: the allowlist arguments feed the same DNS-rebinding
382
+ guard, and a Rack server bound beyond loopback publishes every served bundle
383
+ with no authentication, exactly as `--bind` does.
384
+
385
+ **The one response WEBrick cannot buffer.** The SDK answers the modern
386
+ lifecycle's `subscriptions/listen` with a Rack streaming body — a callable
387
+ that writes SSE frames as they happen and *returns immediately*, having
388
+ registered the stream and started its keepalive thread. WEBrick's proc-body
389
+ path ends the response the moment the proc returns, so the bridge adapts by
390
+ parking: the handler thread waits inside the stream object until the SDK ends
391
+ the stream — a dead peer's `EPIPE` out of a keepalive write, or the
392
+ transport's close at shutdown. Two consequences are load-bearing. Teardown
393
+ must close the transport *before* WEBrick, because WEBrick's shutdown joins
394
+ its connection threads and would hang on any open stream (and on Ruby 2.7 a
395
+ signal trap may not take the transport's mutex, so the trap hands teardown to
396
+ a thread). And the stream cap is this bridge's own — 32, far under the SDK's
397
+ 1000 default — because here every stream parks a thread *and* holds one of
398
+ WEBrick's 100 connection tokens; at the SDK default, tool calls would queue
399
+ behind held streams. A constant, not a flag: zero-config is this mode's
400
+ posture, and an operator who needs more streams has a Rack server, where they
401
+ cost no thread. With no `listChanged` and no `subscribe` among the declared
402
+ capabilities, the honored filter is always empty — a listen stream here
403
+ carries its acknowledgement and keepalives, never a notification, and the
404
+ tests pin that as the conformant answer rather than treating it as a reason
405
+ to refuse the method.
406
+
407
+ **What the Host allowlist is, and is not.** It feeds the SDK's DNS-rebinding
408
+ protection: a browser walked into this port by a page the reader never meant to
409
+ give it to. It is *not* access control, because a client that is not a browser
410
+ sets `Host` to whatever it likes, and there is no authentication behind it. So
411
+ `--bind 0.0.0.0` publishes every served bundle to anything that can reach the
412
+ port, and the boot line warns in those words rather than printing a URL that
413
+ reads as safe to share. Selling the allowlist as the security story would be
414
+ the same overselling the extension seam (`@okf-eco design/extension-points`)
415
+ refuses for the `okf-*` prefix — the false confidence is worse than no rule.
416
+
417
+ Binding publicly is nonetheless allowed, matching the
418
+ graph server (`@okf capabilities/graph-server`): its read surface follows any bind too, and
419
+ only the *write* surface refuses, with **no flag that says otherwise**. The
420
+ surface is read-only by construction (`readOnlyHint` on every tool — fourteen
421
+ today),
422
+ so it sits entirely on the permitted side of that line. The capture write-back
423
+ — one narrow tool through the kernel's validating writer, opt-in per bundle —
424
+ inherits the refusal verbatim when it lands: **loopback only, no override.**
425
+ Recorded here while the write surface does not exist, because that is the only
426
+ moment the boundary is free to draw.
data/.okf/index.md ADDED
@@ -0,0 +1,37 @@
1
+ ---
2
+ okf_version: "0.2"
3
+ ---
4
+
5
+ # okf-mcp knowledge bundle
6
+
7
+ **okf-mcp** is the MCP shell over the okf kernel (`@okf`): any MCP-capable agent
8
+ host can discover, orient in, search and read Open Knowledge Format bundles over
9
+ stdio or Streamable HTTP. This bundle is the gem's structural documentation —
10
+ what the code is, where each responsibility lives, and the rules a change has to
11
+ keep.
12
+
13
+ It is written to be read *before* opening `lib/`, so an agent about to add a
14
+ tool, a transport or a test does not re-derive the layering, and does not
15
+ rebuild something the kernel or this shell already answers. `AGENTS.md` beside
16
+ it is now only the contract and the commands; everything it used to restate
17
+ about the code lives here, once.
18
+
19
+ The **argument** for the server — why each tool exists, the bounded-output
20
+ doctrine, the posture — is [the tool set](design/the-tool-set.md). It used to
21
+ live in the repository's root bundle, back when that bundle was the kernel's and
22
+ described every surface; it came here when the root became the ecosystem's map.
23
+ What a *user* does with the gem stays the README's.
24
+
25
+ `structure/` is pinned: `test/unit/bundle_catalog_test.rb` fails when a file
26
+ under `lib/` is named by no concept, or when a concept names a file that is
27
+ gone. The tree is the truth and this bundle is the claim, so the two cannot
28
+ drift quietly.
29
+
30
+ * [Overview](overview.md) - The gem at a glance: the one rule, the four layers, and what it deliberately is not.
31
+
32
+ # Areas
33
+
34
+ * [Structure](structure/) - Every file under `lib/`, grouped by the layer that owns it: the doors, the served set, the protocol definition, and the HTTP bridge.
35
+ * [Capabilities](capabilities/) - The catalog: fourteen tools, the resources and prompts, and the three transports — what each answers, and what implements it.
36
+ * [Design](design/) - The rules a change has to keep: the inherited floor, the two dependencies, kernel-first, and the single entry point.
37
+ * [Testing](testing/) - How this gem is tested and how to add to it: what each layer proves, and the walk a new tool owes.
data/.okf/log.md ADDED
@@ -0,0 +1,37 @@
1
+ # Update Log
2
+
3
+ ## 2026-08-21
4
+
5
+ * **The freshness stamp watches every file the registry reads** —
6
+ [the tool set](design/the-tool-set.md). okf's registry can now `link` another
7
+ registry file, and that file's bundles resolve into the served set; the stamp
8
+ still stat'd this server's own file alone, so an `okf registry set` over there
9
+ moved nothing it watched and a long-running server kept answering about the
10
+ set it booted with. That is the silent wrong answer the stamp exists to
11
+ prevent, arriving through a second door. The two kinds of file keep **different
12
+ rules**: this server's own registry going unreadable holds the last good set,
13
+ because a file caught mid-write must not empty what is being served, while a
14
+ linked target that vanishes drops its bundles — okf already reports a missing
15
+ one as resolving to nothing, and following that beats freezing it. Riding out
16
+ an error and following a state change are different jobs, and one stamp had to
17
+ do both.
18
+
19
+ * **`list_bundles` names the linked groups too.** They were resolvable as a
20
+ `bundle` argument and absent from the listing, because okf split them into a
21
+ second method — an agent could open `@brain` and never learn it existed. The
22
+ kernel folded them back into `groups_listing`, so this needed no change here
23
+ beyond the `link` key now on each row.
24
+
25
+ ## 2026-08-19
26
+ * **Addition**: **okf-mcp has a knowledge bundle, and `AGENTS.md` now relies on
27
+ it** — [structure](structure/), [capabilities](capabilities/),
28
+ [design](design/), [testing](testing/). The structural layer moved rather
29
+ than being copied: the file-by-file Map, the hard constraints and the testing
30
+ doctrine were `AGENTS.md`'s, and `AGENTS.md` now carries the contract, the
31
+ commands and a pointer here. A fact stated twice is a fact that drifts.
32
+ * **Addition**: **the catalog is pinned, not trusted** —
33
+ [what each test layer proves](testing/layers.md). Structural documentation is
34
+ derived from code, so `test/unit/bundle_catalog_test.rb` fails when a `.rb`
35
+ under `lib/` is named by no concept, when a concept names a file that is gone,
36
+ or when the tool catalog and `server.rb` disagree. The Map it replaces lived
37
+ in `AGENTS.md` where nothing checked it, which is the failure this closes.
data/.okf/overview.md ADDED
@@ -0,0 +1,57 @@
1
+ ---
2
+ type: Overview
3
+ title: okf-mcp at a glance
4
+ description: Four layers between an MCP frame and the okf kernel — the doors, the served set, the server definition, the transports — and the one rule that decides every question about them.
5
+ tags: [mcp, overview, architecture]
6
+ generated:
7
+ by: human:maintainer
8
+ at: 2026-08-19T12:00:00Z
9
+ ---
10
+
11
+ # The one rule
12
+
13
+ **This shell restates nothing the kernel can answer** —
14
+ [kernel-first](design/kernel-first.md), the rule that decides the rest. Every
15
+ tool is a library call into `okf`; logic a tool needs lands in the kernel and is read from there.
16
+ That is what keeps the CLI's answer and the MCP answer from drifting apart, and
17
+ it is the first question to ask of any change here: *could the kernel answer
18
+ this?* If it could, it should, and this gem calls it.
19
+
20
+ The consequence is that okf-mcp is small for what it does.
21
+ [Fourteen tools](capabilities/tools.md) over a 3,000-line `lib/`, most of which is schema, bounded-output arithmetic and the
22
+ HTTP bridge — almost none of it analysis.
23
+
24
+ # Four layers
25
+
26
+ ```
27
+ an MCP host
28
+ │
29
+ │ stdio, Streamable HTTP, or any Rack 3 server
30
+ ▼
31
+ transports cli.rb · http.rb · app.rb
32
+ │
33
+ ▼
34
+ definition server.rb · output_schemas.rb · resources.rb · prompts/
35
+ │ fourteen tools, two prompts, one declared shape per tool
36
+ ▼
37
+ served set registry.rb · filters.rb · backend.rb · memory_backend.rb
38
+ │ which bundles exist, and the cache in front of them
39
+ ▼
40
+ the okf kernel Bundle, Folder, Search, Validator, Linter — all the analysis
41
+ ```
42
+
43
+ Read them in that order and each one only depends on the layer below it. The
44
+ [structure](structure/) area is one concept per layer, and it names every file —
45
+ start at [the doors](structure/doors.md).
46
+
47
+ # What it is not
48
+
49
+ It is not a second implementation of okf, and it is not a writer: every tool is
50
+ a read-only lens, declared as one on the wire. It is also not a program you
51
+ start on its own — there is no `exe/`; `okf mcp` through the kernel's plugin
52
+ seam is the [single entry point](design/one-entry-point.md).
53
+
54
+ The argument for *why* the tool set is what it is — why fourteen and not
55
+ thirty, what `total` means, why domain failures carry the kernel's own
56
+ sentences — is [the tool set](design/the-tool-set.md), not restated here. The
57
+ rest of this bundle is about the code that serves it.
@@ -0,0 +1,50 @@
1
+ ---
2
+ type: Component
3
+ title: The doors, and the load contract
4
+ description: Three ways in — the plugin verb, the Rack app, the library require — and the lazy-loading rule that keeps a bare `require "okf/mcp"` free of protocol machinery.
5
+ tags: [mcp, loading, cli, rack, plugin]
6
+ generated:
7
+ by: human:maintainer
8
+ at: 2026-08-19T12:00:00Z
9
+ resource: lib/okf/mcp.rb
10
+ ---
11
+
12
+ # The files
13
+
14
+ | file | what it owns |
15
+ | ---- | ------------ |
16
+ | `lib/okf/mcp.rb` | the light entry: requires the served-set layer, defines `OKF::MCP` and `OKF::MCP::Error`, and the lazy `OKF::MCP.app` |
17
+ | `lib/okf/plugin.rb` | registers `okf mcp` with the kernel's command registry — the gem's only entry point, since there is no `exe/` |
18
+ | `lib/okf/mcp/cli.rb` | `OKF::MCP::CLI` — the argv shell: flag parsing, the boot announcement, stdio or `--http`, exit codes |
19
+ | `lib/okf/mcp/app.rb` | `OKF::MCP::App` and its `Scope` — transport construction for a Rack server, in exactly one place |
20
+ | `lib/okf/mcp/version.rb` | `OKF::MCP::VERSION` |
21
+
22
+ # The load contract
23
+
24
+ `require "okf/mcp"` loads **the registry seam and the backends only**. The MCP
25
+ SDK, WEBrick and the argv shell arrive on demand — from `okf/mcp/server`,
26
+ `okf/mcp/http`, `okf/mcp/cli`, or the lazy `OKF::MCP.app`, which requires
27
+ `mcp/app` inside the method rather than at the top of the file.
28
+
29
+ This is not tidiness. An application that embeds okf and happens to have
30
+ okf-mcp installed pays for neither the SDK nor WEBrick until something asks for
31
+ the protocol, and the kernel's plugin discovery `require`s `okf/plugin.rb` for
32
+ *every* unknown verb — so a heavy top-level require here would be a tax on
33
+ `okf help`.
34
+
35
+ `test/unit/loading_test.rb` pins it in a clean subprocess: after a bare
36
+ require, neither `::MCP` nor `::WEBrick` is defined. Adding a top-level
37
+ `require` to `lib/okf/mcp.rb` breaks that test, which is the intent.
38
+
39
+ # One place builds a transport
40
+
41
+ `App.build` and `App.transport` exist so that the `--http` path, the Rack path
42
+ and the tests cannot each compose the server differently. `CLI#prepare_http`
43
+ goes through the same seam. When a transport option is added, it is added
44
+ there, once — a second construction site is how two callers come to disagree
45
+ about `allowed_hosts`.
46
+
47
+ `App::Scope` is the Rack middleware that owns the transport's lifetime: it
48
+ answers `call`, and `close` tears the transport down. It is what makes
49
+ `run OKF::MCP.app` in a `config.ru` a complete answer with no rackup file of
50
+ this gem's own — the reader's server is the reader's dependency.