okf-mcp 0.0.0 → 1.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a78bd37b6fbbf5ae6740454269f733373560b8dd2e700f97bfaee18703e22c7c
4
- data.tar.gz: f891e4a6918915312f6dd5b059c962ed90f35cb4326596a51613e6b600661b4c
3
+ metadata.gz: 16fa224a5cb1c83ae561094458afe95726a3f04c94a35e56db94b260a2a213bc
4
+ data.tar.gz: 06027d6762f7f35510d22fc124ecd8e473249bb492baea09238fcd23dafd01a1
5
5
  SHA512:
6
- metadata.gz: 174754a21311bd18764378a70211f0ff4a17c1a141573cf765fcf5ec80d28cef4d6f789c3c2c5b30e756a403af64409f190194c835816a170d43e42f50c293a0
7
- data.tar.gz: 34c4ecb1dc3e665af8b7f93a8b524e0c9d705c785f70596dfb1e138253825f7ee89536e9a2cc8777c963d49d4e023511dbd6a8f3c4d20c33fe1ec3daf5cbcd90
6
+ metadata.gz: 7e37dd09203d6ace0e2a6394a3efdc8551bc3f74bd0e53c7e30c27795cbb7f9fa3975bb9d5697bc4afdcabbef075300386362a0f4f7f79d7ec863beb638f31bb
7
+ data.tar.gz: 4c82c6fdf6fe68a04cc109d4ed3187606f6e276cdfc0cbf0368680d36cd2e2f67c05062304e3698cd473c739de5ac7089c2856c612c742718f5a9e0c251636e6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,217 @@
1
- ## [Unreleased]
1
+ # Changelog
2
2
 
3
- ## [0.1.0] - 2026-07-13
3
+ All notable changes to this project are documented in this file.
4
4
 
5
- - Initial release
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0] - 2026-08-07
9
+
10
+ The first functional release: an MCP server over the okf kernel, judged by
11
+ the kernel's own contracts. Nothing functional precedes it — `0.0.0` below is
12
+ the empty name reservation, and a `0.1.0` section stood here for a version
13
+ that was cut in this file and never published, so its entries have folded in
14
+ rather than pretending to be changes somebody could have seen.
15
+
16
+ ### Added
17
+
18
+ - **`okf mcp`** — the entry point, and the only one. The gem ships no
19
+ executable: it registers a verb with the kernel CLI through the
20
+ `okf/plugin.rb` seam, so installing it is the whole installation and the
21
+ server appears in `okf help` under *installed extensions*. The MCP SDK loads
22
+ only when the verb runs, so `okf help` never pays for it.
23
+ - **Ten read-only tools** mapped straight onto the kernel's library API:
24
+ `list_bundles`, `dirs`, `index`, `search`, `read_concept`, `catalog`, `log`,
25
+ `validate`, `lint`, `graph`. Every list output is bounded with a visible
26
+ `total`, and the bound is on the thing the `total` counts — `log` returns the
27
+ newest three date-grouped entries per file and names how many each holds,
28
+ after the pre-release eval found it answering "what changed recently" with a
29
+ whole 119,863-byte history under a `total` that was counting *files*. Every
30
+ annotation is honest (`readOnlyHint` on all ten, and the handshake declares
31
+ only capabilities that answer); domain failures surface as tool errors
32
+ carrying the kernel's own sentences.
33
+ - **Structured output**: nine of the ten declare an `outputSchema` and emit
34
+ `structuredContent` beside the JSON text, so a host consumes a result instead
35
+ of parsing a blob and guessing. `read_concept` returns markdown, which has no
36
+ object shape to declare.
37
+ - **Bundle identity delegated to the kernel registry**: every `bundle` argument
38
+ is a registry slug — the identity `@slug` resolves at the CLI and
39
+ `/b/<slug>/` mounts on the hub. One argument name on all ten tools, including
40
+ `search`, which is the one that accepts a *set* and briefly said so by
41
+ spelling itself `bundles`: the eval found that the plural's only signal
42
+ arrived after a failed call, because an unknown property is refused by the
43
+ schema before any okf sentence can be written. The type already says it takes
44
+ an array, and the kernel spells the identity slot the same way for every verb. Argv roots are the allowlist (refs and plain
45
+ dirs mix, registered slugs reserved before basenames are deduped); no argv
46
+ serves the active registry, project-local discovery and `OKF_NO_DISCOVERY`
47
+ included. The allowlist is closed at boot as a property of the object rather
48
+ than a promise: argv mode does not retain the kernel registry, so no tool
49
+ argument and no resource URI can widen the served set. Groups fan out for
50
+ `search` and are refused by every single-bundle tool; `"*"` tolerates a
51
+ vanished directory (`skipped` named in the payload) while naming one demands
52
+ it.
53
+ - **The residency layer**: one parsed bundle per root, re-read only when the
54
+ on-disk fingerprint (mtime *and* size) moves, so bodies are always live and
55
+ canonical. Index queries hold one shared corpus per *searched* set — the
56
+ bundles a query names, so federated BM25 scores are comparable by
57
+ construction — built on first use, bounded by an LRU of four, and dropped
58
+ when any member changes. **The identity map
59
+ follows the same rule**: served by the registry, the set of bundles tracks
60
+ the registry file rather than a boot snapshot, so a `set`/`rename`/`del` in
61
+ another terminal lands on the next tool call — and the `resources/list` set
62
+ moves with it, since a resource list derived once at boot drifts out of step
63
+ with the served set it is supposed to describe. A file that cannot be read or
64
+ parsed keeps the last good set and retries, so a server with a working set is
65
+ never taken down by a file it does not own. Argv mode does not follow
66
+ anything — it never carried the registry, so its set cannot widen.
67
+ Two consequences the re-read carries with it, both handled at the request
68
+ seam rather than in each tool: the residency is **pruned to the served
69
+ set** whenever the set moves — the corpus cache with it, since it pins the
70
+ same parsed bundles plus a prepared index over each and its own LRU evicts
71
+ only on an index query, which a scan-only workload never sends — because a
72
+ movable set was the only thing left bounding a cache that never evicted.
73
+ Pruning on *every* request instead took the residency and corpus locks each
74
+ time, queuing an unrelated `list_bundles` — or an initialize handshake —
75
+ behind whatever corpus build another host's index query was holding them
76
+ for; an unchanged set has nothing to prune and takes no locks at all. And
77
+ the fingerprint is **computed once per root per request**, because freshness is
78
+ a question between requests and asking it three times inside one cost three
79
+ full-tree walks under the residency lock (measured: `list_bundles`, `catalog`
80
+ and `search` each went from two walks to one, `search` on the index engine from
81
+ three).
82
+ - **The engine doctrine, the CLI's exactly**: raw-text scan by default,
83
+ `engine: "index"` opt-in for BM25+/page parity, `fuzzy` implying the index,
84
+ `regexp` staying on the scan, incompatible pairs refused with the fix named.
85
+ The payload names the engine that **answered**, resolved rather than echoed —
86
+ `fuzzy` switches engines without being asked, and a caller cannot infer which
87
+ ran, so a miss under the index's tokenizer was indistinguishable from a fact
88
+ the bundle does not carry.
89
+ - **Resources**: every bundle with a root `index.md` at `okf://<slug>`, and
90
+ every concept under the template `okf://{bundle}/{id}` — the affordance no
91
+ tool call has, since a host can attach a document to the context without the
92
+ model deciding to fetch it. Read live through the same residency layer
93
+ `read_concept` uses, and closed by the same allowlist: a URI is not a path.
94
+ Concepts are deliberately not enumerated in `resources/list`, which would
95
+ mean reading every bundle at boot and freezing a list the fingerprint check
96
+ exists to keep honest. The bundle list is recomputed per `resources/list` for
97
+ the same reason — one `stat` each, no bundle read — so *listed implies
98
+ readable* survives the registry moving.
99
+ - **Completions** for the template's `bundle` and `id`, so it is browsable
100
+ rather than a shape you have to know. An unserved bundle, an unknown argument
101
+ and a missing context all complete to nothing — a completion cannot be used
102
+ to probe what argv did not serve.
103
+ - **Two prompts, the consuming pair**: `okf-search` — retrieval as progressive
104
+ disclosure (map, then search, then only the winning bodies, with the engine
105
+ doctrine per query shape) — and `okf-consume` — a bundle as working context
106
+ without reading it whole. Both are this gem's own text, written against the
107
+ tools rather than the CLI. An earlier cut served all eight of the skill's
108
+ playbooks verbatim from the installed kernel, on the argument that a prompt
109
+ is instructions rather than a capability; what that argument missed is
110
+ *whose* instructions they were. Every playbook speaks in `okf …` invocations
111
+ and half dead-end a CLI-less host at "install the CLI first", and five teach
112
+ authoring — a mission every tool here refuses. The consuming doctrine is
113
+ restated in tool vocabulary and shipped in this gem, which is also the cost
114
+ accepted: a doctrine change in the skill must be carried here by hand.
115
+ - **Two transports over one server definition**: stdio (default) and `--http` —
116
+ Streamable HTTP in stateless JSON mode on the WEBrick the kernel already
117
+ ships. A non-loopback bind works (a wildcard `--bind 0.0.0.0` admits this
118
+ machine's own addresses and hostname; `--allow-host` adds a name only a proxy
119
+ or DNS knows) **and warns at boot in plain words**: every served bundle
120
+ becomes readable, without authentication, by anything that can reach the
121
+ port. The Host allowlist those flags feed is a DNS-rebinding defence, not
122
+ access control — a client that is not a browser sets `Host` to whatever it
123
+ likes — and the docs no longer imply otherwise. The bridge caps a request
124
+ body at 4 MiB before allocating it.
125
+ - **Every failure reads as one actionable line.** A boot that cannot bind —
126
+ `okf mcp --http` on a port already serving, which is the likeliest mistake on
127
+ the flag that exists so one warm process is shared — printed an eleven-frame
128
+ backtrace and exited 1 while every other boot failure exited 2 with a sentence;
129
+ `SystemCallError` is rescued with the rest now — around *boot* only: past
130
+ it, the likeliest errno is the host closing its pipes, a stdio session's
131
+ normal end, and routing that through the boot rescue printed the usage
132
+ banner and exited 2, misfiling a shutdown as an operator mistake for any
133
+ supervisor keyed on the exit status. A stdio host hanging up mid-serve exits
134
+ 0 — exactly the two hang-up errnos, on stdio alone; any other mid-serve
135
+ errno propagates as the crash it is rather than borrowing the usage
136
+ banner's exit 2. The split is structural — the HTTP bind happens in boot,
137
+ so EADDRINUSE stays a sentence and exit 2 — and diagnostics are best-effort
138
+ throughout: a closed stderr loses the boot line, never the outcome, where
139
+ it used to re-raise EPIPE out of the boot rescue's own print on stdio and
140
+ to file a lost `--http` boot line as a clean exit 0 for a server that never
141
+ started accepting.
142
+ - **The `okf-sqlite3` seam**: `Backend.detect` soft-requires the engine and
143
+ duck types it; a missing gem, an unloadable native extension and an engine
144
+ that will not construct all degrade to memory, silently except in the boot
145
+ line and `list_bundles.backend`.
146
+ - **The `dir` vocabulary in one place**, since two copies of a rule are two
147
+ answers waiting to disagree: `"."` and `"/"` both name the bundle root, a
148
+ trailing slash is ignored, matching folds case, and a blank value is "no
149
+ filter" rather than a third spelling of the root — a client that fills every
150
+ declared optional property with `""` is doing something routine and must not
151
+ be read as asking for the root. Not the CLI's other spelling either: `--dir
152
+ root` exists there because a shell needs no quoting for it, and importing that
153
+ into a JSON argument would cost any bundle with a real `root/` directory the
154
+ ability to name it.
155
+ - **A `dir` that names no directory is refused by every tool that takes one.**
156
+ `dirs` and `index` have always said so; `catalog` and `search` answered
157
+ `total: 0` and no error, which is the same empty-answer-that-reads-real this
158
+ gem refuses everywhere else. It was worst for the spelling the CLI and the
159
+ bundled skill both teach: an agent asked `catalog` for `root`, was told zero,
160
+ and reported that the bundle root holds no concepts. Across bundles the
161
+ refusal is a fact about the *searched set* — a directory one of three bundles
162
+ has still filters and does not refuse. The set consulted is
163
+ `Bundle#directories` — the same list the `dirs` rows are built from, so the
164
+ refusal and the tool its message points at ("orient with dirs") can never
165
+ disagree; a first cut derived it from the raw file list and accepted a
166
+ directory holding only a file the reader skipped, which `dirs` refuses to
167
+ list. The message carries the one nuance the source cannot: a directory
168
+ standing on disk but holding only unparseable files is refused as exactly
169
+ that — "holds only files the reader could not parse", pointing at validate
170
+ — because "no directory" would be false about the filesystem and sends the
171
+ caller off to re-spell a name that was correct.
172
+ - **`total` means one thing on every tool**: how many rows the request matched,
173
+ before any `limit`. `dirs` and `index` reported the whole bundle's directory
174
+ count so a narrowing stayed visible — defensible alone, wrong as a set, and
175
+ against this gem's own "no silent truncation" promise it read as rows withheld
176
+ by a tool that takes no limit at all.
177
+ - **Every log answer is held to a byte budget `limit` scales.** §7 fixes no
178
+ heading level, so `###` date groups are conformant and the entry split
179
+ cannot see them: the file came back entire under `total: 0, returned: 0` —
180
+ the one unbounded read on this surface surviving the change that was meant
181
+ to close it, and reporting itself as empty. The first fix counted it as one
182
+ indivisible entry cut by size, and left the same read alive one shape over:
183
+ a whole history under a single `## ` heading split into one "entry" and came
184
+ back whole behind a `total: 1` that read as bounded. The budget caps every
185
+ answer now, announced with `truncated: true` — and a scaffolded title with
186
+ no entries yet reports the zero it holds, where counting it as one entry
187
+ told an agent there is history where there is none. Held to its word twice
188
+ over since: the budget is enforced in **bytes** as announced (a character
189
+ count let a multibyte log through at up to 4x the cap, `truncated` silent),
190
+ `returned` is recounted from what survived the cut rather than claiming
191
+ entries whose very headings it removed, and the title's zero holds wherever
192
+ whitespace put the title.
193
+
194
+ ### Fixed
195
+
196
+ - **Only the root path speaks MCP; every path beside it answers 404.** The SDK
197
+ transport routes on method alone, so a bridge that handed it every path
198
+ answered the OAuth-discovery probes a host sends first (`GET /.well-known/*`,
199
+ `POST /register`) with a 405 and a 200-wrapped JSON-RPC parse error — a broken
200
+ sign-in service instead of an absent one, and the host refused the connector
201
+ on it. 404 is the answer a discovering client reads as "no auth here".
202
+
203
+ ### Security
204
+
205
+ - **A symlinked `index.md` can no longer export an arbitrary file.** The kernel's
206
+ read guards (okf 1.13.0) protect every concept, but the root-index resource
207
+ (`okf://<slug>`) read `index.md` directly, outside them — so a bundle whose
208
+ `index.md` was a symlink out of the root served the target verbatim, and listed
209
+ the URI besides. That read is now realpath-checked against the root, and a
210
+ bundle with an escaping index is not listed at all.
211
+
212
+ ## [0.0.0] - 2026-07-14
213
+
214
+ The name reservation on RubyGems: an empty gem, no functionality.
215
+
216
+ [1.0.0]: https://github.com/serradura/okf-gem/releases/tag/okf-mcp/v1.0.0
217
+ [0.0.0]: https://rubygems.org/gems/okf-mcp/versions/0.0.0
data/README.md CHANGED
@@ -1,12 +1,180 @@
1
- # OKF::MCP
1
+ # okf-mcp
2
2
 
3
- A Model Context Protocol server for [OKF](https://github.com/serradura/okf-gem)
4
- it lets any MCP-capable agent host discover, search, and read Open Knowledge
5
- Format bundles through the `okf` kernel.
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) server for
4
+ [OKF](https://okfgem.com) bundles: any MCP-capable agent host Claude Desktop,
5
+ Claude Code, anything speaking the protocol — can discover, orient in, search,
6
+ and read the Open Knowledge Format bundles on a machine.
6
7
 
7
- > **Status:** placeholder release (`0.0.0`) reserving the gem name. The MCP tool
8
- > surface lands in a later version.
8
+ It is a thin shell over the [`okf`](https://rubygems.org/gems/okf) kernel, the
9
+ fourth surface beside its CLI, HTTP server and Ruby library: every tool maps
10
+ onto the kernel's library API, bundles are named by the same registry slugs
11
+ `@slug` resolves at the CLI, and nothing is reimplemented here.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ gem install okf-mcp
17
+ ```
18
+
19
+ Ruby >= 2.7 — the official `mcp` gem's own floor, inherited rather than chosen.
20
+
21
+ Installing the gem is the whole installation: the
22
+ [`okf`](https://rubygems.org/gems/okf) kernel arrives as a dependency, and the
23
+ gem registers an `okf mcp` verb with its CLI through the plugin seam, so
24
+ the server appears in `okf help` under *installed extensions*. There is no
25
+ second binary — this gem ships no executable of its own.
26
+
27
+ ## Run
28
+
29
+ ```bash
30
+ okf mcp <bundle-dir> [<bundle-dir>…] # serve exactly these directories
31
+ okf mcp @handbook ./scratch # registry refs and plain dirs mix
32
+ okf mcp # no args: serve the registered bundles (okf registry set <dir> adds one)
33
+ okf mcp --http # one warm HTTP process instead of stdio-per-host
34
+ ```
35
+
36
+ Stdio is the default — each host spawns its own process, zero config. `--http`
37
+ (`--bind`, default 127.0.0.1; `--port`, default 9134) serves Streamable HTTP in
38
+ stateless JSON mode for every agent at once. The boot line goes to stderr and
39
+ names the backend, the served bundles, and the registry file the slugs came
40
+ from.
41
+
42
+ `--bind` beyond loopback works — a wildcard `0.0.0.0` admits this machine's own
43
+ addresses and hostname, and `--allow-host HOST` (repeatable) adds a name only a
44
+ proxy or DNS knows — and it publishes your bundles. **There is no
45
+ authentication.** Anything that can reach the port can read every served
46
+ bundle, so the boot line says so.
47
+
48
+ The Host allowlist those flags feed is a defence against **DNS rebinding**: a
49
+ browser walked into this port by a page you never meant to give it to. It is
50
+ not access control, and it cannot be — a client that is not a browser sets
51
+ `Host` to whatever it likes. Treat a non-loopback bind the way you would treat
52
+ serving your notes directory over HTTP, because that is what it is.
53
+
54
+ Whatever argv names is the whole served set. A registry ref is resolved once, at
55
+ boot; no tool argument can widen the set afterwards, so a group slug reaches
56
+ bundles only when the registry itself is what is being served.
57
+
58
+ With no arguments the registry *is* what is served, so it is followed rather
59
+ than snapshotted: `okf registry set`, `rename` or `del` in another terminal
60
+ shows up on the next tool call, without a restart. The file is re-read only
61
+ when its fingerprint moves, so the cost is a `stat`. Bundle contents are never
62
+ snapshotted either — bodies are read live, and a bundle is re-parsed whenever
63
+ one of its files changes.
64
+
65
+ ## Host configuration
66
+
67
+ **Run `which okf` and paste the absolute path.** A desktop app is launched by
68
+ the window manager, not by your shell, so it never runs the rc file that puts
69
+ a version manager's Ruby on `PATH` — and if your Ruby came from mise, rbenv,
70
+ asdf or chruby, a bare `"okf"` is unresolvable there. The binstub's shebang
71
+ pins its own Ruby, so the absolute path needs nothing else set up.
72
+
73
+ Claude Desktop (`claude_desktop_config.json`):
74
+
75
+ ```json
76
+ {
77
+ "mcpServers": {
78
+ "okf": { "command": "/absolute/path/from/which/okf", "args": ["mcp"] }
79
+ }
80
+ }
81
+ ```
82
+
83
+ Claude Code inherits your shell, so the bare name is fine there:
84
+
85
+ ```bash
86
+ claude mcp add okf -- okf mcp
87
+ ```
88
+
89
+ Both serve whatever `okf registry` lists; add directories or `@slug`s to `args`
90
+ to pin the set instead.
91
+
92
+ ## Tools
93
+
94
+ Ten read-only tools. Every list answer is bounded and names the full count it
95
+ was cut from — the rows the request matched, before any `limit`:
96
+
97
+ | Tool | What it answers |
98
+ |---|---|
99
+ | `list_bundles` | what exists: slug, title, root, concept count, type/tag rollups, the default, the groups, the backend |
100
+ | `dirs` | the shape — one row per directory with direct and subtree counts; **the first move** |
101
+ | `index` | the index map: authored index bodies, rollups, listings, one directory at a time (depth 1 by default) |
102
+ | `search` | pointed questions: ANDed terms, scored rows carrying the fields they matched, across several bundles at once |
103
+ | `read_concept` | one concept's file, verbatim and live from disk; ids are exact |
104
+ | `catalog` | per-concept metadata with link degrees; filters, paging, field projection |
105
+ | `log` | every `log.md`, root first, live — the newest 3 dated entries per file, each answer held to a byte budget `limit` scales; a cut says `truncated` |
106
+ | `validate` | the spec §9 conformance verdict |
107
+ | `lint` | the curation-quality report; `group: "folder"` lists the unlinked files by folder |
108
+ | `graph` | the knowledge graph in three bounded views: minimal, hubs, traffic — never with bodies |
109
+
110
+ Each returns its JSON twice: as text, and as `structuredContent` against a
111
+ declared `outputSchema`, so a host consumes a result without parsing a blob and
112
+ guessing at its shape. `read_concept` is the exception — markdown has no object
113
+ shape to declare.
114
+
115
+ ## Prompts
116
+
117
+ Two prompts, the consuming pair: `okf-search` — retrieval as progressive
118
+ disclosure (map, then search, then only the winning bodies), with the engine
119
+ doctrine per query shape — and `okf-consume` — using a bundle as working
120
+ context without reading it whole. Both are this gem's own text, written
121
+ against the tools above rather than the CLI, so they work on a host with no
122
+ shell and no filesystem.
123
+
124
+ The okf skill's playbooks are deliberately not served. They speak in `okf …`
125
+ invocations, and the authoring ones — produce, migrate, maintain, refine,
126
+ curate — carry a mission every tool here refuses: this server makes a client
127
+ an expert *consumer* of bundles. Authoring stays with the skill
128
+ (`okf skill <dest>`), installed where a filesystem and the CLI actually are.
129
+
130
+ ## Resources
131
+
132
+ Every bundle with a root `index.md` is a resource at `okf://<slug>`, and every
133
+ concept is one under the template `okf://{bundle}/{id}` — so a host can *attach*
134
+ a document to the context directly, instead of the model having to decide to
135
+ fetch it. Both are read live from disk, and `bundle` and `id` complete, so the
136
+ template is browsable rather than a shape you have to know.
137
+
138
+ Concepts are not enumerated in `resources/list`: that would mean reading every
139
+ bundle at boot — exactly the eager work this server avoids, since bundles are
140
+ parsed on first use and re-read only when their files change — and it would
141
+ freeze a list that live reading is meant to keep honest.
142
+
143
+ ## Engines
144
+
145
+ Search matches raw text by default — exact, no tokenizer, milliseconds over an
146
+ already-parsed bundle. `engine: "index"` opts into BM25+ ranking on a held corpus,
147
+ the same engine and version the `okf server` page runs, so the two rank
148
+ identically. `fuzzy` implies the index and its tokenizer; `regexp` stays on the
149
+ raw-text scan; incompatible pairs are refused with the fix named.
150
+
151
+ Every result names the engine that **answered** it, because `fuzzy` picks one
152
+ without being asked and no score tells you which ran. It matters when something
153
+ is missing: under the index's tokenizer a shattered identifier and an absent
154
+ fact look identical, so knowing you were on the index is what turns "not here"
155
+ back into "try the scan". The doctrine runs the other way too — the scan is
156
+ exact, so a typo or a stemmed form misses on it, and that miss is what
157
+ `engine: "index"` and `fuzzy` are for.
158
+
159
+ ## Beside a browser
160
+
161
+ The MCP surface is for agents; humans get the same bundles as an interactive
162
+ graph:
163
+
164
+ ```bash
165
+ okf server # the registered bundles, one hub, http://127.0.0.1:8808
166
+ ```
167
+
168
+ Run it beside the MCP config and both read the same live files.
169
+
170
+ ## Development
171
+
172
+ ```bash
173
+ bin/setup # install dependencies
174
+ bundle exec rake # tests + RuboCop — what CI runs
175
+ bundle exec rake test:integration # the critical layer alone + coverage/integration/
176
+ ```
9
177
 
10
178
  ## License
11
179
 
12
- Apache-2.0. See [LICENSE.txt](LICENSE.txt) and [NOTICE](NOTICE).
180
+ Apache-2.0, see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ module MCP
5
+ # The seam between the shell and the optional okf-sqlite3 engine. One
6
+ # `require` answers both "is it installed?" and "does the native extension
7
+ # load?" — a broken sqlite3 build degrades to the memory backend instead
8
+ # of crashing the server, silently except in the boot line and
9
+ # list_bundles.backend. Feature detection, not version pinning: the engine
10
+ # only has to answer the duck type below, so the gems release
11
+ # independently. Backends speak symbol-keyed rows in the kernel's catalog
12
+ # and search shapes; `score` and `snippet` are optional extras.
13
+ module Backend
14
+ ENGINE_METHODS = %i[refresh search catalog capabilities].freeze
15
+
16
+ def self.detect
17
+ engine = build_engine
18
+ suitable?(engine) ? engine : MemoryBackend.new
19
+ rescue LoadError, StandardError
20
+ MemoryBackend.new
21
+ end
22
+
23
+ # Both ways the optional engine can fail are on this side of the seam, so
24
+ # the one rescue above covers both: the `require` (absent gem, native
25
+ # extension that will not load) and the construction (a connection it
26
+ # cannot open, a schema check, a constant renamed out from under us).
27
+ # Catching only LoadError left the second half fatal, which is exactly
28
+ # what "degrades instead of crashing the server" promises it is not.
29
+ def self.build_engine
30
+ require "okf/sqlite3"
31
+ OKF::Sqlite3::Backend.new
32
+ end
33
+
34
+ def self.suitable?(engine)
35
+ ENGINE_METHODS.all? { |name| engine.respond_to?(name) }
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ require_relative "server"
6
+
7
+ module OKF
8
+ module MCP
9
+ # The argv-facing shell: parses options, builds the one server definition,
10
+ # and hands it to a transport — stdio by default (each agent host spawns
11
+ # its own process), --http for one warm process serving every agent. Exit
12
+ # codes keep the kernel CLI's contract: 0 ok, 2 usage error. The boot line
13
+ # goes to stderr because stdout is the stdio protocol channel.
14
+ class CLI
15
+ USAGE = "usage: okf mcp [options] [<bundle-dir>|@slug ...] " \
16
+ "(no args: serve the bundles registered with `okf registry`)"
17
+
18
+ DEFAULT_BIND = "127.0.0.1"
19
+ DEFAULT_PORT = 9134
20
+
21
+ # Two streams, because they carry different things. +out+ is the
22
+ # diagnostic channel — the boot line, the notes, the refusals — and
23
+ # defaults to stderr since stdout belongs to the stdio protocol. +stdout+
24
+ # is the human channel the two informational flags use, and is a
25
+ # parameter rather than a literal `$stdout` so the `okf mcp` verb can
26
+ # hand over the streams the kernel injected into it.
27
+ def self.run(argv, out: $stderr, stdout: $stdout)
28
+ new(argv, out: out, stdout: stdout).run
29
+ end
30
+
31
+ def initialize(argv, out: $stderr, stdout: $stdout)
32
+ @argv = argv
33
+ @out = out
34
+ @stdout = stdout
35
+ @http = false
36
+ @bind = DEFAULT_BIND
37
+ @port = DEFAULT_PORT
38
+ @allow_hosts = []
39
+ @done = false
40
+ end
41
+
42
+ # Boot, then serve — structurally, because the two phases carry
43
+ # different exit contracts and the rescue below must never see a serving
44
+ # error. `SystemCallError` belongs in the boot rescue for the same
45
+ # reason the tool wrapper rescues it: an errno is a fact about the
46
+ # operator's machine, not a bug, and it must read as one line rather
47
+ # than a backtrace. The bind is where it actually bites — `--http`
48
+ # exists so one warm process is shared, which makes "that port is
49
+ # already serving" the likeliest mistake, and it came back as eleven
50
+ # frames and exit 1 while every other boot failure exited 2 with a
51
+ # sentence. The HTTP bind happens *here*, in boot (see #prepare_http),
52
+ # precisely so that stays true.
53
+ def run
54
+ server = nil
55
+ begin
56
+ args = parser.parse(@argv)
57
+ return 0 if @done
58
+
59
+ registry = args.empty? ? Registry.from_kernel : Registry.from_argv(args)
60
+ registry.boot_notes.each { |note| say("okf-mcp: #{note}") }
61
+ engine = Backend.detect
62
+ server = Server.build(registry, engine: engine)
63
+ announce(registry, engine)
64
+ prepare_http(server) if @http
65
+ rescue Error, OKF::Error, OptionParser::ParseError, SystemCallError => e
66
+ say("okf-mcp: #{e.message}")
67
+ say(USAGE)
68
+ return 2
69
+ end
70
+
71
+ serve(server)
72
+ end
73
+
74
+ private
75
+
76
+ # Serving, past the boot rescue's reach. On stdio the likeliest errno is
77
+ # the host closing its pipes — the session's normal end, not a mistake —
78
+ # and nothing is printed: the streams belong to the host that just hung
79
+ # up. The carve-out is exactly those two errnos, and stdio's alone: on
80
+ # `--http` a hang-up errno cannot mean "the session ended" (boot output
81
+ # already went through #say), and any other mid-serve errno on either
82
+ # transport is a runtime fault hours past a valid invocation — it
83
+ # propagates as the crash it is, never the usage banner's exit 2.
84
+ def serve(server)
85
+ @http ? @httpd.start : serve_stdio(server)
86
+ 0
87
+ rescue Errno::EPIPE, Errno::ECONNRESET
88
+ raise if @http
89
+
90
+ 0
91
+ end
92
+
93
+ # Diagnostics are best-effort: @out belongs to whoever spawned the
94
+ # process, and a closed stderr must not decide the outcome — it did,
95
+ # twice: the boot rescue's own puts re-raised EPIPE as a backtrace for
96
+ # a normal hang-up, and the serve rescue filed a lost `--http` boot
97
+ # line as a clean exit 0 for a server that never started.
98
+ def say(line)
99
+ @out.puts(line)
100
+ rescue Errno::EPIPE, Errno::ECONNRESET
101
+ nil
102
+ end
103
+
104
+ def parser
105
+ OptionParser.new do |opts|
106
+ opts.banner = USAGE
107
+ opts.on("--http", "serve Streamable HTTP instead of stdio") { @http = true }
108
+ opts.on("--bind HOST", "HTTP bind address (default #{DEFAULT_BIND})") { |value| @bind = value }
109
+ opts.on("--port PORT", Integer, "HTTP port (default #{DEFAULT_PORT})") { |value| @port = value }
110
+ opts.on("--allow-host HOST", "admit this Host header (repeatable; for a DNS name",
111
+ "or a reverse proxy no local interface knows about)") { |value| @allow_hosts << value }
112
+ opts.on("-h", "--help", "print this help") do
113
+ @stdout.puts(opts)
114
+ @done = true
115
+ end
116
+ opts.on("--version", "print the version") do
117
+ @stdout.puts(VERSION)
118
+ @done = true
119
+ end
120
+ end
121
+ end
122
+
123
+ # The one-look diagnosis for "why these bundles?" and "why no ranked
124
+ # results?": which backend answered detection, which bundles are served
125
+ # under which slugs, and which registry file the names came from.
126
+ def announce(registry, engine)
127
+ bundles = registry.entries.map { |entry| "#{entry.slug} (#{entry.root})" }.join(", ")
128
+ source = registry.source ? " — registry: #{registry.source}" : ""
129
+ say("okf-mcp #{VERSION} — backend: #{engine.capabilities[:name]} — bundles: #{bundles}#{source}")
130
+ end
131
+
132
+ def serve_stdio(server)
133
+ transport = ::MCP::Server::Transports::StdioTransport.new(server)
134
+ server.transport = transport
135
+ transport.open
136
+ end
137
+
138
+ # The bind, the traps and the boot line — everything that can fail as a
139
+ # boot failure — so #run's rescue files an EADDRINUSE as the usage error
140
+ # it is, while whatever #serve raises later is manifestly not boot.
141
+ def prepare_http(server)
142
+ require_relative "http"
143
+ @httpd = HTTP.prepare(server, bind: @bind, port: @port, allow_hosts: @allow_hosts, out: @out)
144
+ end
145
+ end
146
+ end
147
+ end