okf-mcp 1.2.0 → 1.2.1

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,408 @@
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
+ Four consequences of that rule had to be chased down after it landed. Two are
123
+ the same mistake in different clothes — **something else was still a boot
124
+ snapshot** — and two are what a *movable* served set costs a cache that was
125
+ written for a fixed one. The residency held one parsed bundle per root and never
126
+ evicted, which was bounded only by the set being fixed at boot; it is pruned to
127
+ the served set whenever the set *moves* now — not on every request, which took
128
+ the residency and corpus locks and queued every unrelated call behind whatever
129
+ index build another host was paying for — since an operator repointing entries
130
+ otherwise retains every root the registry has ever named — and the prune
131
+ reaches the corpus cache too, which pins the same parsed bundles plus a
132
+ prepared index over each: its LRU evicts only on an index query, so a
133
+ scan-only workload would have held a dropped root's corpus forever. And the on-disk
134
+ fingerprint is taken once per root per request rather than per ask: freshness is
135
+ a question *between* requests, and asking it three times inside one cost three
136
+ full-tree walks under the residency lock (`search` on the index engine measured
137
+ three, now one). Both hang off the request seam, which is the two public
138
+ entry points and deliberately not `handle_request` — the JSON-RPC layer treats
139
+ the handler block as a lookup and calls what it returns afterwards, so a wrapper
140
+ there encloses nothing. The resource list was computed once and handed to the SDK as a
141
+ fixed array, so a bundle registered afterwards was never advertised and a
142
+ removed one stayed advertised until a read of the very URI we published came
143
+ back "unknown bundle" — it is derived per `resources/list` now, at the one
144
+ `stat` per bundle it always cost. And the *stamp itself* was taken after the
145
+ boot read rather than before it, so a write landing in between was recorded as
146
+ already-seen: entries from before it, fingerprint from after, and nothing to do
147
+ until some further write moved the fingerprint again. Booting stamps nothing
148
+ now; the first call re-reads. A stamp is a claim about a file you have already
149
+ read, and it may never be taken later than the read it labels.
150
+ Federation runs through `Search.across`/one corpus, so BM25 scores are
151
+ comparable by construction. The engine doctrine is the CLI's exactly: scan by
152
+ default, `engine: "index"` opt-in, `fuzzy` implying the index, `regexp` on the
153
+ scan, incompatible pairs refused naming the fix.
154
+
155
+ # Bounded outputs, honest errors
156
+
157
+ Every list output carries a visible `total` — `search` caps at 20 rows,
158
+ `catalog` pages at 200, rollups cap at 25 with `other_*` remainders, `index`
159
+ descends one level unless asked, and `graph` serves three views that never
160
+ carry bodies (the dump anti-pattern stays unreachable).
161
+
162
+ **A `total` bounds nothing unless it counts the thing that grows.** `log`
163
+ carried one from the start and was the one unbounded read on the surface: it
164
+ counted log *files*, so `total: 1` sat above this repo's entire 119,863-byte
165
+ history — the answer to "what changed recently" scaling with the project's age
166
+ rather than the question. The pre-release ROI eval found it, and on the same
167
+ recommended path the instructions name. It now returns the newest three
168
+ date-grouped entries per file (§9's own structure) with each file's `total` and
169
+ `returned`, which cut that answer to 13,491 bytes and a whole eval session in
170
+ half. The split stays here rather than in the kernel because bounding for a
171
+ 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
172
+ general shape: **a bound that counts containers instead of contents reads as
173
+ bounded and is not**, which is the same false-comfort class as a capability
174
+ declared by default.
175
+
176
+ That shape kept producing instances, each found by review rather than by use.
177
+ §9 fixes no heading level, so a log grouped under `###` is conformant and the
178
+ `## ` split cannot see it: the file came back *whole* under `total: 0`, an
179
+ unbounded read advertising itself as empty, and `limit` could not reach the
180
+ path at all. The first fix counted it as one indivisible entry cut by size —
181
+ and left the same read alive one shape over, where a whole history under a
182
+ single `## ` heading split into one "entry" and came back whole behind a
183
+ `total: 1` that read as bounded. The byte budget `limit` scales now caps
184
+ *every* answer, announced with `truncated` — inventing a boundary would be
185
+ inventing a format, but declaring a bound is only honest. Both of the answer's
186
+ units keep their word: the budget is enforced in **bytes** as announced (a
187
+ character count let a multibyte log through at up to 4x the cap, silently),
188
+ and `returned` is recounted from what *survived* the cut, never claiming an
189
+ entry whose heading the cut removed. A scaffolded title with no entries yet
190
+ reports the zero it holds instead of one entry of history that does not exist
191
+ — wherever whitespace put the title. And `total` itself meant two things: the rows
192
+ matched in `catalog`/`search`, the whole bundle's directory count in
193
+ `dirs`/`index`. Each defensible alone; as a set it made the larger number read
194
+ as rows withheld from a tool that takes no limit. One key, one question.
195
+
196
+ **An empty answer that reads like a real one is the same failure wearing
197
+ zero.** `dirs` and `index` refused a `dir` naming no directory; `catalog` and
198
+ `search` answered `total: 0` to it. Worst for `root` — the spelling the
199
+ CLI (`@okf cli`) and the skill (`@okf capabilities/agent-skill`) both teach — where an agent
200
+ asked for the bundle root, was told zero, and reported that the bundle root
201
+ holds nothing. Every tool taking a `dir` refuses one now, and across bundles the
202
+ refusal is a fact about the searched *set*: a directory one of three bundles has
203
+ still filters, because otherwise the ordinary cross-bundle ask would break.
204
+ The set consulted is `Bundle#directories` — the same list the `dirs` rows are
205
+ built from — because the refusal's own advice is "orient with dirs", and a
206
+ first cut that derived it from the raw file list accepted directories `dirs`
207
+ refuses to list (one holding only a file the reader skipped). One question,
208
+ one source, on both sides of the refusal. The message carries the one nuance
209
+ the source cannot: a directory standing on disk but holding only unparseable
210
+ files is refused as exactly that — "holds only files the reader could not
211
+ parse", pointing at `validate` — because "no directory" would be false about
212
+ the filesystem and sends the caller off to re-spell a name that was correct
213
+ when the fix is repairing the files.
214
+
215
+ Kernel refusals become
216
+ `isError` tool responses carrying the kernel's own sentences; the tool
217
+ descriptions carry the skill's retrieval doctrine (orient with `dirs`, descend
218
+ with `index`, search for pointed questions, read only winners), because they
219
+ are the only playbook a Desktop host ever sees. Two prompts — the consuming
220
+ pair — restate that doctrine in full, in tool vocabulary; why only two is the
221
+ section below.
222
+
223
+ # One entry point: the `okf mcp` verb
224
+
225
+ The gem is separate because it has to be: the `mcp` SDK's floor is 2.7 against
226
+ the kernel's 2.4, and it brings five transitive dependencies to a tool whose
227
+ runtime set is deliberately three. Neither fact argues for a separate
228
+ *command*, and conflating the two questions is what left `okf mcp` unbuilt at
229
+ 0.1.0. The extension seam (`@okf-eco design/extension-points`) exists precisely so
230
+ the dependency stays on the addon's side of the line: `okf-mcp/lib/okf/plugin.rb`
231
+ registers the verb, the baseline names nothing, and a 2.4 machine simply cannot
232
+ install the gem (`required_ruby_version` refuses). The kernel floor is the same
233
+ kind of promise and easier to break silently: the gemspec must name the okf
234
+ release that ships every API the shell calls (`>= 1.13`, for
235
+ `Bundle#directories`), because the monorepo's path pin satisfies any floor and
236
+ hides one that lies — the declared minimum admitted a kernel the code raised
237
+ NoMethodError against, and no test can notice without installing the old gem.
238
+ So the verb is free, and what
239
+ it buys is discoverability — the server appears in `okf help` under *installed
240
+ extensions* on the machines that have it, rather than waiting to be known about.
241
+
242
+ Once the verb existed, the `exe/okf-mcp` beside it was a second name for the
243
+ same `CLI.run` — one more thing to install, document, spell correctly in a host
244
+ config and keep working. It went, before the first release, while removing a
245
+ name still cost nobody anything; after one it would have been a break for every
246
+ config that spelled it. The generalizable half is the timing, not the deletion:
247
+ **an entry point is a compatibility promise from the moment it ships**, so the
248
+ window for having second thoughts closes at the first release, not at the first
249
+ complaint.
250
+
251
+ Three obligations come with routing a protocol server through a CLI dispatcher.
252
+ **Stdout stays pure**: the kernel's dispatch path writes plugin diagnostics and
253
+ unknown-verb refusals to stderr, never stdout, and `MCP::CLI` now takes the
254
+ human channel as a parameter instead of writing to `$stdout` — so the verb
255
+ honors the injected streams and no boot line can corrupt the first frame. A
256
+ spawned test asserts the first byte on stdout is a JSON-RPC frame. **The SDK
257
+ loads inside `#call`**, not at the top of the plugin file, because discovery
258
+ requires that file for `okf help` and for every unknown verb; a `require` at
259
+ load time would charge every one of those runs for a server nobody asked for.
260
+ **The exit contract is structural.** Boot and serve are separate phases in the
261
+ verb itself: everything that can fail as an operator mistake — argv, the
262
+ registry, the HTTP bind — happens under a rescue that answers exit 2 with one
263
+ line, and nothing raised while serving can reach it. On stdio the two hang-up
264
+ errnos are the session's normal end, exit 0; any other mid-serve errno
265
+ propagates as the crash it is. Diagnostics are best-effort throughout, because
266
+ a dead stderr must not decide a server's fate: before the split, the boot
267
+ rescue's own print re-raised EPIPE as a backtrace for a normal hang-up, and a
268
+ lost `--http` boot line read as a clean exit 0 for a server that never started
269
+ accepting.
270
+
271
+ # What the protocol offers that tools do not
272
+
273
+ Ten tools mapped the CLI's read verbs and stopped there, which left most of MCP
274
+ unused. Three additions came from comparing the surface against the protocol
275
+ rather than against the CLI.
276
+
277
+ **Resources** are the one affordance a tool call cannot provide: a host can
278
+ *attach* a document to the context itself, without the model deciding to fetch
279
+ it. Every bundle with a root `index.md` is `okf://<slug>`; every concept is
280
+ covered by the template `okf://{bundle}/{id}`. Concepts are deliberately *not*
281
+ enumerated — that would read every bundle at boot, the eager work the residency
282
+ layer exists to avoid, and would freeze a list the fingerprint check keeps
283
+ honest. The template is signage only: the SDK binds a variable to `[^/]+` and
284
+ every OKF id below the root carries a slash, so the parsing is ours. The
285
+ allowlist holds on this surface too — a URI is not a path, and a slug in one is
286
+ still only a key into the served map.
287
+
288
+ **Completions** make the template browsable instead of a shape you must already
289
+ know, and they are where containment is easiest to lose: an unserved bundle, an
290
+ unknown argument and a missing context all complete to nothing, so no
291
+ completion can confirm what argv did not serve.
292
+
293
+ **Structured output** ends the blob: every JSON tool declares its shape and
294
+ emits `structuredContent` beside the text. Declaring a shape is also what
295
+ exposes what the shape leaves out — `search` named its query, its bundles and
296
+ its rows, and not the **engine that answered**, which `fuzzy` selects without
297
+ being asked. Nothing in the result recovers it: the scan's integer count and
298
+ the index's BM25 float both round to a number. So a miss under the index's
299
+ tokenizer (`@okf capabilities/search`) — a shattered identifier, a documented recall hole —
300
+ was indistinguishable from a fact the bundle does not hold, which is the
301
+ silent wrong answer again in its quietest form. The schemas are proven rather than
302
+ asserted — the suite runs every tool through every variant with the SDK's
303
+ result validation switched on, while production leaves it off, because a schema
304
+ bug should fail a test rather than turn a working tool into a runtime error.
305
+
306
+ The honesty rule the `readOnlyHint` annotations already followed turned out to
307
+ be broken at the handshake: passing no `capabilities:` inherited the SDK's
308
+ default, which announced `resources` while `resources/list` answered `[]`,
309
+ `logging` that nothing emitted through, and `listChanged` on lists nothing
310
+ notifies about. Declaring them explicitly is the fix, and the general shape is
311
+ the same one the containment hole taught: **a default you did not choose is
312
+ still a claim you made.**
313
+
314
+ `listChanged` stays undeclared even now that the resource list genuinely moves,
315
+ because the test is whether anything *notifies*, not whether anything changes.
316
+ A host that re-lists sees the current set; one that caches the boot listing is
317
+ stale until it asks again. Declaring the capability is what would fix that, and
318
+ it waits on something actually sending the notification — announcing it first
319
+ would only invite a host to wait for one that never comes.
320
+
321
+ # The prompts are the consuming pair, in tool vocabulary
322
+
323
+ The prompt surface took three cuts to find its principle. Four of the
324
+ skill's (`@okf capabilities/agent-skill`) nine playbooks shipped first, selected by nothing
325
+ better than their names resembling tools. The second cut served all eight
326
+ (everything but `doctor`, whose premise — install the CLI — anything reaching
327
+ this server has disproved), on the argument that **a prompt is instructions,
328
+ not a capability**: the writing a playbook describes is done by the host's own
329
+ tools, so the read-only posture excluded nothing.
330
+
331
+ That argument is true and was still the wrong test, because it never asked
332
+ *whose* instructions they were. Every playbook speaks in `okf …` invocations,
333
+ half dead-end a CLI-less host at "install the CLI first" (`menu`'s step 1
334
+ literally ends "Everything below needs the CLI"), six link on into a reference
335
+ tree this server does not serve — and five teach authoring, a mission every
336
+ tool here refuses. To the host this surface exists for, they taught a
337
+ vocabulary it cannot use toward work it cannot do. The right test is the
338
+ mission: this server makes a client an expert **consumer** of bundles, so it
339
+ serves the two consuming playbooks — `okf-search` and `okf-consume`, in
340
+ `SKILL.md`'s own order — rewritten against the tools (`list_bundles`, `dirs`,
341
+ `index`, `search`, `read_concept`, `log`, `graph`), with the engine doctrine
342
+ and the anti-patterns carried over in the tools' argument spellings. A test
343
+ pins the voice: every tool the texts name must exist on the wire, and no
344
+ backticked CLI invocation survives.
345
+
346
+ The rewrite has a real cost, accepted knowingly: the texts are okf-mcp's own
347
+ (`lib/okf/mcp/prompts/`), no longer read from the installed kernel, so a
348
+ doctrine change in the skill must be carried here by hand. What it bought is
349
+ that the prompts work where they are served — and it dissolved the version-skew
350
+ failure mode the old path carried, where a kernel that renamed a playbook left
351
+ this server advertising a file that is gone. Authoring stays with the skill,
352
+ installed where a filesystem and the CLI actually are.
353
+
354
+ # Transports and posture
355
+
356
+ One server definition, two transports: stdio (default — each host spawns its
357
+ own process, boot line on stderr) and `--http` — Streamable HTTP in stateless
358
+ JSON mode on the WEBrick the kernel already ships. A third hosting, not a
359
+ third transport: `OKF::MCP.app` hands the same definition and stateless
360
+ transport to any Rack server a `config.ru` names — the server is the
361
+ reader's dependency, never this gem's, so the no-rackup position holds while
362
+ puma and its kin stop being closed doors. The seam inherits this section's
363
+ whole posture verbatim: the allowlist arguments feed the same DNS-rebinding
364
+ guard, and a Rack server bound beyond loopback publishes every served bundle
365
+ with no authentication, exactly as `--bind` does.
366
+
367
+ **The one response WEBrick cannot buffer.** The SDK answers the modern
368
+ lifecycle's `subscriptions/listen` with a Rack streaming body — a callable
369
+ that writes SSE frames as they happen and *returns immediately*, having
370
+ registered the stream and started its keepalive thread. WEBrick's proc-body
371
+ path ends the response the moment the proc returns, so the bridge adapts by
372
+ parking: the handler thread waits inside the stream object until the SDK ends
373
+ the stream — a dead peer's `EPIPE` out of a keepalive write, or the
374
+ transport's close at shutdown. Two consequences are load-bearing. Teardown
375
+ must close the transport *before* WEBrick, because WEBrick's shutdown joins
376
+ its connection threads and would hang on any open stream (and on Ruby 2.7 a
377
+ signal trap may not take the transport's mutex, so the trap hands teardown to
378
+ a thread). And the stream cap is this bridge's own — 32, far under the SDK's
379
+ 1000 default — because here every stream parks a thread *and* holds one of
380
+ WEBrick's 100 connection tokens; at the SDK default, tool calls would queue
381
+ behind held streams. A constant, not a flag: zero-config is this mode's
382
+ posture, and an operator who needs more streams has a Rack server, where they
383
+ cost no thread. With no `listChanged` and no `subscribe` among the declared
384
+ capabilities, the honored filter is always empty — a listen stream here
385
+ carries its acknowledgement and keepalives, never a notification, and the
386
+ tests pin that as the conformant answer rather than treating it as a reason
387
+ to refuse the method.
388
+
389
+ **What the Host allowlist is, and is not.** It feeds the SDK's DNS-rebinding
390
+ protection: a browser walked into this port by a page the reader never meant to
391
+ give it to. It is *not* access control, because a client that is not a browser
392
+ sets `Host` to whatever it likes, and there is no authentication behind it. So
393
+ `--bind 0.0.0.0` publishes every served bundle to anything that can reach the
394
+ port, and the boot line warns in those words rather than printing a URL that
395
+ reads as safe to share. Selling the allowlist as the security story would be
396
+ the same overselling the extension seam (`@okf-eco design/extension-points`)
397
+ refuses for the `okf-*` prefix — the false confidence is worse than no rule.
398
+
399
+ Binding publicly is nonetheless allowed, matching the
400
+ graph server (`@okf capabilities/graph-server`): its read surface follows any bind too, and
401
+ only the *write* surface refuses, with **no flag that says otherwise**. The
402
+ surface is read-only by construction (`readOnlyHint` on every tool — fourteen
403
+ today),
404
+ so it sits entirely on the permitted side of that line. The capture write-back
405
+ — one narrow tool through the kernel's validating writer, opt-in per bundle —
406
+ inherits the refusal verbatim when it lands: **loopback only, no override.**
407
+ Recorded here while the write surface does not exist, because that is the only
408
+ 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,15 @@
1
+ # Update Log
2
+
3
+ ## 2026-08-19
4
+ * **Addition**: **okf-mcp has a knowledge bundle, and `AGENTS.md` now relies on
5
+ it** — [structure](structure/), [capabilities](capabilities/),
6
+ [design](design/), [testing](testing/). The structural layer moved rather
7
+ than being copied: the file-by-file Map, the hard constraints and the testing
8
+ doctrine were `AGENTS.md`'s, and `AGENTS.md` now carries the contract, the
9
+ commands and a pointer here. A fact stated twice is a fact that drifts.
10
+ * **Addition**: **the catalog is pinned, not trusted** —
11
+ [what each test layer proves](testing/layers.md). Structural documentation is
12
+ derived from code, so `test/unit/bundle_catalog_test.rb` fails when a `.rb`
13
+ under `lib/` is named by no concept, when a concept names a file that is gone,
14
+ or when the tool catalog and `server.rb` disagree. The Map it replaces lived
15
+ 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.
@@ -0,0 +1,56 @@
1
+ ---
2
+ type: Component
3
+ title: The HTTP bridge
4
+ description: WEBrick to Rack in one file — buffered responses, the streaming adapter that parks a handler thread until the SDK ends a stream, and the teardown order that stops it hanging.
5
+ tags: [mcp, http, webrick, streaming, teardown]
6
+ generated:
7
+ by: human:maintainer
8
+ at: 2026-08-19T12:00:00Z
9
+ resource: lib/okf/mcp/http.rb
10
+ ---
11
+
12
+ # The file
13
+
14
+ | file | what it owns |
15
+ | ---- | ------------ |
16
+ | `lib/okf/mcp/http.rb` | `OKF::MCP::HTTP` — `prepare`, `stop`, `app_for`, `build`, `handle`, and the two stream classes `Stream` and `Streams` |
17
+
18
+ This is the only file that knows WEBrick exists. `--http` goes through it;
19
+ `OKF::MCP.app` under a Rack 3 server does not, which is why the streaming
20
+ subtleties below are scoped to this bridge and not to the gem.
21
+
22
+ # The subtlety the whole file is shaped around
23
+
24
+ `subscriptions/listen` is answered by the SDK with a **Rack streaming body
25
+ whose callable returns immediately**. WEBrick ends a proc-body response when
26
+ the proc returns. Composed naively, every listen would close the instant it
27
+ opened.
28
+
29
+ So `Stream#wait` parks the handler thread until the SDK ends the stream, and
30
+ `Streams` is the bounded set of live ones. Three consequences are load-bearing,
31
+ and `test/integration/http_listen_test.rb` pins each:
32
+
33
+ - **Teardown closes the transport before WEBrick.** `HTTP.stop` does them in
34
+ that order because WEBrick's shutdown joins its connection threads and hangs
35
+ on any open stream.
36
+ - **The signal trap hands teardown to a thread.** A mutex in trap context is a
37
+ `ThreadError` on 2.7, which is the floor.
38
+ - **Listens are capped at 32, on this bridge only.** Each holds a WEBrick
39
+ thread and a connection token. That is not true under a Rack server, where
40
+ the SDK's own default stands, so the cap belongs here rather than in the
41
+ server definition.
42
+
43
+ **`EPIPE` must propagate.** A dead peer is noticed by `EPIPE` raising out of a
44
+ keepalive write, and that propagation *is* the SDK's cleanup signal. An adapter
45
+ that rescues it leaks the stream instead of closing it — so the rescue that
46
+ looks defensive is the bug.
47
+
48
+ # Host and origin checking
49
+
50
+ `allowed_hosts_for` and `local_hosts` derive the default allowlist from the
51
+ bind address. `app_for` is where `allowed_hosts` and `allowed_origins` reach
52
+ the transport, and it is called from [`App`](doors.md) rather than duplicated —
53
+ one construction site, so a new option cannot land on half the callers.
54
+
55
+ `read_body` bounds the request body and `oversized` is its refusal;
56
+ `not_found` answers anything off the MCP path.
@@ -0,0 +1,13 @@
1
+ # Structure
2
+
3
+ Every file under `lib/`, grouped by the layer that owns it. One concept owns
4
+ each file, and `test/unit/bundle_catalog_test.rb` fails if that stops being
5
+ true in either direction — a file no concept names, or a concept naming a file
6
+ that is gone.
7
+
8
+ Read them bottom-up: each layer depends only on the one below it.
9
+
10
+ * [The doors](doors.md) - `lib/okf/mcp.rb`, `lib/okf/plugin.rb`, `lib/okf/mcp/cli.rb`, `lib/okf/mcp/app.rb`, `lib/okf/mcp/version.rb` — the three ways in, and the load contract that keeps a bare require cheap.
11
+ * [The served set](served-set.md) - `lib/okf/mcp/registry.rb`, `filters.rb`, `backend.rb`, `memory_backend.rb` — which bundles exist, and the cache in front of them.
12
+ * [The server definition](server-definition.md) - `lib/okf/mcp/server.rb`, `output_schemas.rb`, `resources.rb` — the fourteen tools, their declared shapes, and concepts as resources.
13
+ * [The HTTP bridge](http-bridge.md) - `lib/okf/mcp/http.rb` — WEBrick to Rack, and the streaming adapter that the rest of the gem does not need to know about.