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 +4 -4
- data/CHANGELOG.md +215 -3
- data/README.md +175 -7
- data/lib/okf/mcp/backend.rb +39 -0
- data/lib/okf/mcp/cli.rb +147 -0
- data/lib/okf/mcp/filters.rb +81 -0
- data/lib/okf/mcp/http.rb +223 -0
- data/lib/okf/mcp/memory_backend.rb +213 -0
- data/lib/okf/mcp/output_schemas.rb +134 -0
- data/lib/okf/mcp/prompts/consume.md +17 -0
- data/lib/okf/mcp/prompts/search.md +73 -0
- data/lib/okf/mcp/registry.rb +353 -0
- data/lib/okf/mcp/resources.rb +193 -0
- data/lib/okf/mcp/server.rb +1035 -0
- data/lib/okf/mcp/version.rb +1 -1
- data/lib/okf/mcp.rb +11 -0
- data/lib/okf/plugin.rb +55 -0
- metadata +50 -9
- data/.gitignore +0 -12
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 16fa224a5cb1c83ae561094458afe95726a3f04c94a35e56db94b260a2a213bc
|
|
4
|
+
data.tar.gz: 06027d6762f7f35510d22fc124ecd8e473249bb492baea09238fcd23dafd01a1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7e37dd09203d6ace0e2a6394a3efdc8551bc3f74bd0e53c7e30c27795cbb7f9fa3975bb9d5697bc4afdcabbef075300386362a0f4f7f79d7ec863beb638f31bb
|
|
7
|
+
data.tar.gz: 4c82c6fdf6fe68a04cc109d4ed3187606f6e276cdfc0cbf0368680d36cd2e2f67c05062304e3698cd473c739de5ac7089c2856c612c742718f5a9e0c251636e6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,217 @@
|
|
|
1
|
-
|
|
1
|
+
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
#
|
|
1
|
+
# okf-mcp
|
|
2
2
|
|
|
3
|
-
A Model Context Protocol
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
|
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
|
data/lib/okf/mcp/cli.rb
ADDED
|
@@ -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
|