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.
- checksums.yaml +4 -4
- data/.okf/capabilities/index.md +9 -0
- data/.okf/capabilities/resources-and-prompts.md +45 -0
- data/.okf/capabilities/tools.md +73 -0
- data/.okf/capabilities/transports.md +39 -0
- data/.okf/design/index.md +11 -0
- data/.okf/design/kernel-first.md +42 -0
- data/.okf/design/one-entry-point.md +36 -0
- data/.okf/design/ruby-floor.md +43 -0
- data/.okf/design/runtime-dependencies.md +45 -0
- data/.okf/design/the-tool-set.md +408 -0
- data/.okf/index.md +37 -0
- data/.okf/log.md +15 -0
- data/.okf/overview.md +57 -0
- data/.okf/structure/doors.md +50 -0
- data/.okf/structure/http-bridge.md +56 -0
- data/.okf/structure/index.md +13 -0
- data/.okf/structure/served-set.md +58 -0
- data/.okf/structure/server-definition.md +72 -0
- data/.okf/testing/adding-a-tool.md +65 -0
- data/.okf/testing/index.md +9 -0
- data/.okf/testing/layers.md +56 -0
- data/CHANGELOG.md +54 -4
- data/README.md +10 -0
- data/lib/okf/mcp/version.rb +1 -1
- metadata +28 -7
|
@@ -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.
|