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
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
module MCP
|
|
5
|
+
# The `dir` vocabulary, in one place.
|
|
6
|
+
#
|
|
7
|
+
# It lived in two — one copy in the server, one in the memory backend, each
|
|
8
|
+
# commented "the CLI's one rule kept verbatim" — and they drifted: only one
|
|
9
|
+
# of them mapped `""` and `"/"` onto the root, so `catalog(dir: "/")`
|
|
10
|
+
# reported an empty bundle root while `search` and `dirs` answered for it.
|
|
11
|
+
# Two copies of a rule is two answers waiting to disagree, so there is one.
|
|
12
|
+
#
|
|
13
|
+
# There are genuinely **two** rules here, and the distinction is not drift:
|
|
14
|
+
#
|
|
15
|
+
# #under_dir? filters *concepts*. A dir names itself and everything
|
|
16
|
+
# beneath it; `.` is a prefix of nothing, so the root
|
|
17
|
+
# selects only what lives directly in it (the CLI's `--dir`).
|
|
18
|
+
# #within? scopes a *tree* of directory rows, where the root is the
|
|
19
|
+
# ancestor of every row (`dirs`/`index` walking down).
|
|
20
|
+
module Filters
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def fold(value)
|
|
24
|
+
value.to_s.downcase
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A `dir` argument as the tools compare it: case-folded, trailing slashes
|
|
28
|
+
# gone, and both spellings of the bundle root — "." and "/" — normalized
|
|
29
|
+
# to ".".
|
|
30
|
+
#
|
|
31
|
+
# A *blank* argument never reaches here as a filter. Every concept filter
|
|
32
|
+
# short-circuits on `OKF.blank?` first, because a client that fills each
|
|
33
|
+
# declared optional property with "" is doing something routine and means
|
|
34
|
+
# "no filter" by it — so "" and an omitted `dir` are the same ask, and
|
|
35
|
+
# neither is a synonym for the root. In the tree scoping the two coincide
|
|
36
|
+
# anyway: `.` is the ancestor of every row, so a blank normalizes to "."
|
|
37
|
+
# and selects the whole tree.
|
|
38
|
+
#
|
|
39
|
+
# Deliberately **not** the CLI's `fold_dir`, which also folds the literal
|
|
40
|
+
# name "root". Its whole rationale there is that a shell needs no quoting
|
|
41
|
+
# for it; a JSON argument has no such problem, and no `dir` description on
|
|
42
|
+
# this surface ever advertised the spelling. Importing it cost a bundle
|
|
43
|
+
# with a real `root/` directory the ability to name it — `dir: "root"`
|
|
44
|
+
# answered for the bundle root instead, and `scoped_rows` skips its
|
|
45
|
+
# existence check for the root, so not even the refusal fired.
|
|
46
|
+
def normalize_dir(value)
|
|
47
|
+
folded = fold(value).sub(%r{/+\z}, "")
|
|
48
|
+
folded.empty? || folded == "." ? "." : folded
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Concept filtering: `dir` names itself and everything beneath it.
|
|
52
|
+
def under_dir?(entry_dir, wanted)
|
|
53
|
+
entry = fold(entry_dir)
|
|
54
|
+
path = normalize_dir(wanted)
|
|
55
|
+
entry == path || entry.start_with?("#{path}/")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Tree scoping: the root is the ancestor of every row, so `.` selects
|
|
59
|
+
# the whole tree rather than only what sits directly in it.
|
|
60
|
+
def within?(entry_dir, base)
|
|
61
|
+
return true if base == "."
|
|
62
|
+
|
|
63
|
+
entry = fold(entry_dir)
|
|
64
|
+
entry == base || entry.start_with?("#{base}/")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def dir_depth(dir)
|
|
68
|
+
dir == "." ? 0 : dir.split("/").length
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Is +wanted+ a directory this set actually has? Folded, and blind to a
|
|
72
|
+
# trailing slash, so it accepts the spelling the views print back.
|
|
73
|
+
def known_dir?(dirs, wanted)
|
|
74
|
+
base = normalize_dir(wanted)
|
|
75
|
+
return true if base == "."
|
|
76
|
+
|
|
77
|
+
dirs.any? { |dir| fold(dir) == base }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/okf/mcp/http.rb
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require "webrick"
|
|
6
|
+
|
|
7
|
+
require_relative "server"
|
|
8
|
+
|
|
9
|
+
module OKF
|
|
10
|
+
module MCP
|
|
11
|
+
# Bridges the SDK's StreamableHTTPTransport — a plain Rack app — onto the
|
|
12
|
+
# WEBrick that okf already depends on, mirroring the kernel's
|
|
13
|
+
# server/runner pattern (rack and webrick both arrive via the okf gem).
|
|
14
|
+
# Stateless JSON mode: every POST is a self-contained JSON-RPC exchange
|
|
15
|
+
# answered with a single JSON object, so plain buffered responses hold —
|
|
16
|
+
# no SSE stream to keep open. The SDK validates Host/Origin against DNS
|
|
17
|
+
# rebinding for locally bound servers.
|
|
18
|
+
module HTTP
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# The Hosts the SDK's StreamableHTTPTransport admits without any extra
|
|
22
|
+
# allowlist — its DNS-rebinding protection accepts these out of the box.
|
|
23
|
+
LOOPBACK_HOSTS = %w[127.0.0.1 ::1 localhost].freeze
|
|
24
|
+
|
|
25
|
+
# Binds that mean "every interface" rather than one address.
|
|
26
|
+
WILDCARD_BINDS = %w[0.0.0.0 :: *].freeze
|
|
27
|
+
|
|
28
|
+
# The largest request body the bridge will hand the transport, matching
|
|
29
|
+
# the SDK's own StreamableHTTPTransport default. Anything past it is 413
|
|
30
|
+
# before it is allocated (see #read_body).
|
|
31
|
+
MAX_REQUEST_BYTES = 4 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
# Everything before accepting — the bind (where EADDRINUSE, the boot
|
|
34
|
+
# failure that actually happens, raises), the traps and the boot line —
|
|
35
|
+
# split from #start so the CLI can run this under its *boot* rescue and
|
|
36
|
+
# file a bind failure as the usage error it is, while a mid-serve errno
|
|
37
|
+
# out of #start stays the crash it is.
|
|
38
|
+
def prepare(server, bind:, port:, allow_hosts: [], out: $stderr)
|
|
39
|
+
app = app_for(server, bind: bind, allowed_hosts: allowed_hosts_for(bind, extra: allow_hosts))
|
|
40
|
+
httpd = build(app, bind: bind, port: port)
|
|
41
|
+
%w[INT TERM].each { |signal| trap(signal) { httpd.shutdown } }
|
|
42
|
+
announce(httpd, bind: bind, out: out)
|
|
43
|
+
httpd
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The SDK transport wired to the server, in stateless JSON mode. A
|
|
47
|
+
# non-loopback bind (e.g. 0.0.0.0) is refused by the SDK's DNS-rebinding
|
|
48
|
+
# guard unless its Host is allowlisted; loopback binds keep the SDK
|
|
49
|
+
# defaults. Protection itself stays on either way.
|
|
50
|
+
def app_for(server, bind:, allowed_hosts: allowed_hosts_for(bind))
|
|
51
|
+
options = { stateless: true, enable_json_response: true, max_request_bytes: MAX_REQUEST_BYTES }
|
|
52
|
+
options[:allowed_hosts] = allowed_hosts if allowed_hosts && !allowed_hosts.empty?
|
|
53
|
+
app = ::MCP::Server::Transports::StreamableHTTPTransport.new(server, **options)
|
|
54
|
+
server.transport = app
|
|
55
|
+
app
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The Host allowlist a bind address needs: nil for loopback, which the
|
|
59
|
+
# SDK already admits.
|
|
60
|
+
#
|
|
61
|
+
# A **wildcard** bind is the case that was broken. `--bind 0.0.0.0` says
|
|
62
|
+
# "serve every interface", and allowlisting the literal string "0.0.0.0"
|
|
63
|
+
# allowlists a Host header no client ever sends: the server bound
|
|
64
|
+
# everything and answered every real request with 403. What a client
|
|
65
|
+
# actually sends is the address it dialled, so a wildcard expands to this
|
|
66
|
+
# machine's own addresses plus its hostname.
|
|
67
|
+
#
|
|
68
|
+
# +extra+ (the repeatable --allow-host) covers what cannot be derived: a
|
|
69
|
+
# DNS name or a reverse proxy's Host, which no local interface knows.
|
|
70
|
+
# The boot line, plus what a non-loopback bind actually means.
|
|
71
|
+
#
|
|
72
|
+
# The Host allowlist below is a defence against **DNS rebinding** — a
|
|
73
|
+
# browser walked into this port by a page the reader never meant to give
|
|
74
|
+
# it to. It is not access control and must never be sold as one: a client
|
|
75
|
+
# that is not a browser sets `Host` to whatever it likes, and there is no
|
|
76
|
+
# authentication behind it. So binding anywhere but loopback publishes
|
|
77
|
+
# every served bundle to anything that can reach the port, and the boot
|
|
78
|
+
# line says so rather than reading like a URL somebody can safely share.
|
|
79
|
+
#
|
|
80
|
+
# Loopback stays quiet: it is the default and the posture the tool was
|
|
81
|
+
# built for, and a warning printed every time is a warning nobody reads.
|
|
82
|
+
def announce(httpd, bind:, out:)
|
|
83
|
+
# The listener's own port, not the asked-for one: with --port 0 the OS
|
|
84
|
+
# picks, and a boot line reporting 0 would name a port nothing answers.
|
|
85
|
+
bound = httpd.listeners.first.addr[1]
|
|
86
|
+
say(out, "okf-mcp listening on http://#{bind}:#{bound}")
|
|
87
|
+
return if LOOPBACK_HOSTS.include?(bind.to_s)
|
|
88
|
+
|
|
89
|
+
say(out, "okf-mcp: WARNING — #{bind} is not loopback. Every served bundle is")
|
|
90
|
+
say(out, " readable by anything that can reach this port, with no authentication. The Host")
|
|
91
|
+
say(out, " allowlist only stops browser DNS rebinding; it is not access control.")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The boot line is diagnostics, and diagnostics are best-effort: stderr
|
|
95
|
+
# belongs to whoever spawned the process, and a collector that died must
|
|
96
|
+
# not take a bound, healthy server down with it. An EPIPE here is lost
|
|
97
|
+
# output, not a lost server.
|
|
98
|
+
def say(out, line)
|
|
99
|
+
out.puts(line)
|
|
100
|
+
rescue Errno::EPIPE, Errno::ECONNRESET
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def allowed_hosts_for(bind, extra: [])
|
|
105
|
+
extra = Array(extra).reject { |host| OKF.blank?(host) }
|
|
106
|
+
return (extra.empty? ? nil : extra) if LOOPBACK_HOSTS.include?(bind)
|
|
107
|
+
|
|
108
|
+
hosts = WILDCARD_BINDS.include?(bind.to_s) ? local_hosts : [ bind ]
|
|
109
|
+
(hosts + extra).uniq
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Every address this machine answers on, plus its hostname. Loopback is
|
|
113
|
+
# left out only because the SDK admits it already.
|
|
114
|
+
def local_hosts
|
|
115
|
+
addresses = Socket.ip_address_list.reject(&:ipv4_loopback?).reject(&:ipv6_loopback?)
|
|
116
|
+
names = addresses.map(&:ip_address)
|
|
117
|
+
names << Socket.gethostname.to_s
|
|
118
|
+
names.reject { |name| OKF.blank?(name) }.uniq
|
|
119
|
+
rescue SocketError, SystemCallError
|
|
120
|
+
[]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Returns an unstarted server so tests can drive an ephemeral port.
|
|
124
|
+
def build(app, bind:, port:)
|
|
125
|
+
httpd = WEBrick::HTTPServer.new(
|
|
126
|
+
BindAddress: bind,
|
|
127
|
+
Port: port,
|
|
128
|
+
Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN),
|
|
129
|
+
AccessLog: []
|
|
130
|
+
)
|
|
131
|
+
httpd.mount_proc("/") { |request, response| handle(app, request, response) }
|
|
132
|
+
httpd
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def handle(app, request, response)
|
|
136
|
+
# The MCP endpoint is the root and nothing else. The SDK transport
|
|
137
|
+
# routes on method alone, so handing it every path answered the OAuth
|
|
138
|
+
# discovery probes a connecting host sends first (GET /.well-known/*,
|
|
139
|
+
# POST /register — Claude Desktop does) with a 405 and a 200-wrapped
|
|
140
|
+
# JSON-RPC parse error: a *broken* sign-in service instead of an
|
|
141
|
+
# absent one, and the host refused the connector on it. 404 is the
|
|
142
|
+
# answer that reads as absence.
|
|
143
|
+
return not_found(response) unless request.path == "/"
|
|
144
|
+
|
|
145
|
+
# The cap is enforced *here*, before the body is materialized. The SDK
|
|
146
|
+
# transport has its own `max_request_bytes` and never reads more than
|
|
147
|
+
# that off `rack.input` — but WEBrick's `request.body` has no limit, so
|
|
148
|
+
# handing the transport a StringIO of it meant the allocation the cap
|
|
149
|
+
# exists to prevent had already happened. A few concurrent 2 GB POSTs
|
|
150
|
+
# would OOM the warm shared process that `--http` exists to provide.
|
|
151
|
+
body = read_body(request)
|
|
152
|
+
return oversized(response) if body.nil?
|
|
153
|
+
|
|
154
|
+
status, headers, out = app.call(env_for(request, body))
|
|
155
|
+
response.status = status
|
|
156
|
+
headers.each { |name, value| response[name] = value }
|
|
157
|
+
buffer = String.new
|
|
158
|
+
out.each { |chunk| buffer << chunk }
|
|
159
|
+
response.body = buffer
|
|
160
|
+
ensure
|
|
161
|
+
out.close if out.respond_to?(:close)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The request body, or nil when it exceeds MAX_REQUEST_BYTES. A declared
|
|
165
|
+
# Content-Length past the cap is refused without reading a byte; an
|
|
166
|
+
# undeclared (chunked) body is streamed and abandoned the moment it grows
|
|
167
|
+
# past it.
|
|
168
|
+
def read_body(request)
|
|
169
|
+
# The raw header, not WEBrick's #content_length: that helper is
|
|
170
|
+
# `Integer(self["content-length"])`, which raises TypeError on a
|
|
171
|
+
# chunked request that legitimately carries no Content-Length at all.
|
|
172
|
+
declared = request["content-length"]
|
|
173
|
+
return nil if declared && declared.to_i > MAX_REQUEST_BYTES
|
|
174
|
+
|
|
175
|
+
# Always the block form: `request.body` with no block materializes the
|
|
176
|
+
# whole entity body, which for an undeclared (chunked) request is the
|
|
177
|
+
# very allocation this method exists to bound. With a block WEBrick
|
|
178
|
+
# streams, and a request carrying no body simply never yields.
|
|
179
|
+
buffer = String.new
|
|
180
|
+
request.body do |chunk|
|
|
181
|
+
buffer << chunk
|
|
182
|
+
return nil if buffer.bytesize > MAX_REQUEST_BYTES
|
|
183
|
+
end
|
|
184
|
+
buffer
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def not_found(response)
|
|
188
|
+
response.status = 404
|
|
189
|
+
response["Content-Type"] = "application/json"
|
|
190
|
+
response.body = JSON.generate(error: "not found: the MCP endpoint is /")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def oversized(response)
|
|
194
|
+
response.status = 413
|
|
195
|
+
response["Content-Type"] = "application/json"
|
|
196
|
+
response.body = JSON.generate(
|
|
197
|
+
jsonrpc: "2.0", id: nil,
|
|
198
|
+
error: { code: -32_600, message: "Request body exceeds #{MAX_REQUEST_BYTES} bytes" }
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def env_for(request, body = request.body.to_s)
|
|
203
|
+
env = {
|
|
204
|
+
"REQUEST_METHOD" => request.request_method,
|
|
205
|
+
"SCRIPT_NAME" => "",
|
|
206
|
+
"PATH_INFO" => request.path,
|
|
207
|
+
"QUERY_STRING" => request.query_string.to_s,
|
|
208
|
+
"SERVER_NAME" => request.host.to_s,
|
|
209
|
+
"SERVER_PORT" => request.port.to_s,
|
|
210
|
+
"rack.url_scheme" => "http",
|
|
211
|
+
"rack.input" => StringIO.new(body.to_s),
|
|
212
|
+
"rack.errors" => $stderr
|
|
213
|
+
}
|
|
214
|
+
request.each do |name, value|
|
|
215
|
+
key = name.upcase.tr("-", "_")
|
|
216
|
+
key = "HTTP_#{key}" unless %w[CONTENT_TYPE CONTENT_LENGTH].include?(key)
|
|
217
|
+
env[key] = value
|
|
218
|
+
end
|
|
219
|
+
env
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
module MCP
|
|
5
|
+
# The residency cache and the corpus holder — the long-lived holder's branch
|
|
6
|
+
# of the kernel's lifecycle asymmetry. One parsed bundle per registered
|
|
7
|
+
# root, re-read only when the on-disk fingerprint (the markdown file list
|
|
8
|
+
# plus the newest mtime) moves; bodies always come from this layer, live
|
|
9
|
+
# from disk and canonical, whichever engine answers search. There is
|
|
10
|
+
# deliberately no disk cache here — the process is the cache; disk-side
|
|
11
|
+
# state is okf-sqlite3's territory.
|
|
12
|
+
class MemoryBackend
|
|
13
|
+
FILTER_KEYS = %i[type tag dir status].freeze
|
|
14
|
+
|
|
15
|
+
# How many prepared corpora to hold at once, most-recently-used first.
|
|
16
|
+
# It used to be unbounded, keyed on the queried subset — a long-lived
|
|
17
|
+
# --http process answering `bundles: ["a","b"]`, then ["a","c"], then
|
|
18
|
+
# ["b","c","d"] retained a full index for each of up to 2^n subsets until
|
|
19
|
+
# the box swapped. Four covers the real pattern ("*" plus a couple of
|
|
20
|
+
# working sets) and bounds the worst case at four indexes.
|
|
21
|
+
MAX_CORPORA = 4
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
@cache = {}
|
|
26
|
+
@corpus_mutex = Mutex.new
|
|
27
|
+
@corpora = {}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def capabilities
|
|
31
|
+
{ name: "memory", ranked: false }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def refresh(root)
|
|
35
|
+
folder(root)
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Engine doctrine, the CLI's exactly: the scan answers by default — no
|
|
40
|
+
# tokenizer recall holes, milliseconds over a resident bundle — and the
|
|
41
|
+
# index (BM25+, page parity) is opt-in by name or implied by fuzzy. The
|
|
42
|
+
# kernel routes and refuses incompatible asks (regexp needs the scan,
|
|
43
|
+
# fuzzy needs the index); those refusals surface as tool errors.
|
|
44
|
+
#
|
|
45
|
+
# Index queries go through a held corpus: one shared index over the whole
|
|
46
|
+
# served set, so federated BM25 scores are comparable by construction
|
|
47
|
+
# (Search.across's own argument). The corpus is built on the first index
|
|
48
|
+
# query, not at boot, and dropped when any member bundle's fingerprint
|
|
49
|
+
# moves — a held index outliving its set is a wrong answer, not a slow one.
|
|
50
|
+
def search_pairs(pairs, terms, fields: nil, regexp: false, fuzzy: false, engine: nil)
|
|
51
|
+
bundles = pairs.map { |slug, root| [ slug, folder(root).bundle ] }
|
|
52
|
+
options = { fields: fields, regexp: regexp, fuzzy: fuzzy, engine: engine }
|
|
53
|
+
if index_query?(engine, fuzzy: fuzzy)
|
|
54
|
+
Bundle::Search.with(corpus_for(pairs), terms, **options)
|
|
55
|
+
else
|
|
56
|
+
Bundle::Search.across(bundles, terms, **options)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def catalog(root, filters = {})
|
|
61
|
+
folder(root).catalog.select { |row| matches?(row, filters) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Drop every parsed bundle the caller no longer serves. The residency was
|
|
65
|
+
# bounded only by the served set being fixed at boot, and the registry
|
|
66
|
+
# re-read ended that: an operator repointing entries (a checkout per
|
|
67
|
+
# branch, rotating CI worktrees, remove-then-add) left every root the
|
|
68
|
+
# registry had *ever* named keyed here, each holding a parsed bundle with
|
|
69
|
+
# its id maps memoized, until the box swapped and every connected host
|
|
70
|
+
# dropped at once. The sibling corpus cache has been capped since it was
|
|
71
|
+
# written; this one lost its bound without gaining one.
|
|
72
|
+
#
|
|
73
|
+
# An LRU would be the obvious cap and the wrong one: the natural bound is
|
|
74
|
+
# the served set, and a cap below it would thrash — re-parsing on every
|
|
75
|
+
# call for any operator who registered more bundles than the number
|
|
76
|
+
# guessed.
|
|
77
|
+
#
|
|
78
|
+
# The corpus cache is pruned by the same rule, because it pins the same
|
|
79
|
+
# parsed bundles — plus a prepared index over each. Its LRU is not a
|
|
80
|
+
# substitute: eviction there happens only on an *index* query, which a
|
|
81
|
+
# scan-only workload never sends, so a corpus over a repointed root
|
|
82
|
+
# stayed resident indefinitely and the memory this prune claims to
|
|
83
|
+
# release was still held.
|
|
84
|
+
def retain(roots)
|
|
85
|
+
kept = {}
|
|
86
|
+
roots.each { |root| kept[root] = true }
|
|
87
|
+
@mutex.synchronize { @cache.delete_if { |root, _| !kept.key?(root) } }
|
|
88
|
+
@corpus_mutex.synchronize { @corpora.delete_if { |key, _| key.any? { |root| !kept.key?(root) } } }
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# One unit of work, for the fingerprint memo below. The freshness check
|
|
93
|
+
# belongs *between* requests: within one, a tool asks the engine for the
|
|
94
|
+
# catalog and then reads the unparseable count off the same folder, and
|
|
95
|
+
# each ask re-walked the whole tree — a glob plus a stat per markdown
|
|
96
|
+
# file, all of it inside the lock.
|
|
97
|
+
def during_request
|
|
98
|
+
previous = Thread.current.thread_variable_get(:okf_mcp_prints)
|
|
99
|
+
Thread.current.thread_variable_set(:okf_mcp_prints, {})
|
|
100
|
+
yield
|
|
101
|
+
ensure
|
|
102
|
+
Thread.current.thread_variable_set(:okf_mcp_prints, previous)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The disk handle behind read_concept, index and dirs — those stay on the
|
|
106
|
+
# parsed bundle whichever engine answers search: bodies are read live from
|
|
107
|
+
# disk (canonical), and the directory index needs the authored index.md
|
|
108
|
+
# bodies no derived store carries.
|
|
109
|
+
def folder(root)
|
|
110
|
+
@mutex.synchronize do
|
|
111
|
+
entry = @cache[root]
|
|
112
|
+
print = print_for(root)
|
|
113
|
+
unless entry && entry[:fingerprint] == print
|
|
114
|
+
folder = Bundle::Folder.load(root)
|
|
115
|
+
# The threaded HTTP transport can hit these lazy memos from two
|
|
116
|
+
# threads at once; build them once here, under this lock.
|
|
117
|
+
folder.bundle.concept_by_id(nil)
|
|
118
|
+
folder.bundle.paths_by_id
|
|
119
|
+
folder.bundle.directories
|
|
120
|
+
entry = { folder: folder, fingerprint: print }
|
|
121
|
+
@cache[root] = entry
|
|
122
|
+
end
|
|
123
|
+
entry[:folder]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# The fingerprint, computed at most once per root per request. Outside a
|
|
130
|
+
# request (a library caller, a warm-up `refresh`) there is no memo and
|
|
131
|
+
# every call walks, which is the old behaviour and the safe default: a
|
|
132
|
+
# memo that outlived its request would be a stale answer, and staleness is
|
|
133
|
+
# the one thing this layer exists to prevent.
|
|
134
|
+
def print_for(root)
|
|
135
|
+
memo = Thread.current.thread_variable_get(:okf_mcp_prints)
|
|
136
|
+
return fingerprint(root) if memo.nil?
|
|
137
|
+
|
|
138
|
+
memo.key?(root) ? memo[root] : (memo[root] = fingerprint(root))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Does this query need the prepared index? A named "index" engine, or
|
|
142
|
+
# fuzzy with no engine named (the kernel would route it to the index
|
|
143
|
+
# anyway). "scan" plus fuzzy deliberately skips the corpus so the kernel
|
|
144
|
+
# can refuse it with its own sentence.
|
|
145
|
+
def index_query?(engine, fuzzy:)
|
|
146
|
+
engine.to_s == "index" || (fuzzy && OKF.blank?(engine))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# One corpus per searched set, keyed by its roots and pinned to the exact
|
|
150
|
+
# folder objects it was built from. #folder returns a new object only
|
|
151
|
+
# when a fingerprint moved, so identity comparison is the staleness check.
|
|
152
|
+
#
|
|
153
|
+
# Both the key and the pinned folders are sorted, so `["a","b"]` and
|
|
154
|
+
# `["b","a"]` are the same entry rather than two that evict each other on
|
|
155
|
+
# every alternation. The cache is an LRU bounded at MAX_CORPORA: re-inserting
|
|
156
|
+
# on a hit moves the entry to the end, and the oldest is dropped past the cap.
|
|
157
|
+
def corpus_for(pairs)
|
|
158
|
+
sorted = pairs.sort_by { |slug, root| [ root, slug.to_s ] }
|
|
159
|
+
folders = sorted.map { |slug, root| [ slug, folder(root) ] }
|
|
160
|
+
key = sorted.map { |_, root| root }
|
|
161
|
+
@corpus_mutex.synchronize do
|
|
162
|
+
held = @corpora.delete(key)
|
|
163
|
+
held = nil unless held && held[:folders] == folders
|
|
164
|
+
held ||= { corpus: Bundle::Search.prepare(folders.map { |slug, f| [ slug, f.bundle ] }, engine: "index"), folders: folders }
|
|
165
|
+
@corpora[key] = held
|
|
166
|
+
@corpora.shift while @corpora.size > MAX_CORPORA
|
|
167
|
+
held[:corpus]
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def matches?(row, filters)
|
|
172
|
+
FILTER_KEYS.all? do |key|
|
|
173
|
+
wanted = filters[key]
|
|
174
|
+
next true if OKF.blank?(wanted)
|
|
175
|
+
|
|
176
|
+
case key
|
|
177
|
+
when :tag then Array(row[:tags]).map { |tag| Filters.fold(tag) }.include?(Filters.fold(wanted))
|
|
178
|
+
when :dir then Filters.under_dir?(row[:dir], wanted)
|
|
179
|
+
else Filters.fold(row[key]) == Filters.fold(wanted)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# What the residency cache watches: every markdown file with its mtime
|
|
185
|
+
# **and its size**.
|
|
186
|
+
#
|
|
187
|
+
# The size is what makes a same-second edit visible. mtime alone has
|
|
188
|
+
# 1-second granularity on HFS+, NFS and Docker bind mounts, so a second
|
|
189
|
+
# save inside one tick left the file list and the newest mtime unmoved
|
|
190
|
+
# and the bundle was never re-read — against the server's own promise
|
|
191
|
+
# that results reflect the current files. A same-second edit that also
|
|
192
|
+
# preserves the byte count is still invisible; catching that needs a
|
|
193
|
+
# content hash on every call, which is the whole cost this cache exists
|
|
194
|
+
# to avoid.
|
|
195
|
+
#
|
|
196
|
+
# `stat` is rescued per path because the glob is a moment old by the time
|
|
197
|
+
# it is walked: a `git checkout` or `rm` mid-call would otherwise raise
|
|
198
|
+
# ENOENT and answer the tool with an errno instead of results. A file
|
|
199
|
+
# that vanished simply drops out — which changes the fingerprint, which
|
|
200
|
+
# is exactly the reload the disappearance should trigger.
|
|
201
|
+
def fingerprint(root)
|
|
202
|
+
Dir.glob(::File.join(root, "**", "*.md")).sort.map do |path|
|
|
203
|
+
stat = begin
|
|
204
|
+
::File.stat(path)
|
|
205
|
+
rescue SystemCallError
|
|
206
|
+
next nil
|
|
207
|
+
end
|
|
208
|
+
[ path, stat.mtime.to_f, stat.size ]
|
|
209
|
+
end.compact
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
module MCP
|
|
5
|
+
# The shape each tool answers with, declared so a host consumes a result
|
|
6
|
+
# instead of parsing a blob and guessing. Every tool emits both: the JSON
|
|
7
|
+
# text in `content` (what a pre-2025-06-18 client reads) and the same
|
|
8
|
+
# object in `structuredContent`.
|
|
9
|
+
#
|
|
10
|
+
# They live in one table rather than beside each tool because the useful
|
|
11
|
+
# question is comparative — *every* list view carries `total`, and reading
|
|
12
|
+
# them together is what keeps that true. `define_tool` looks the name up;
|
|
13
|
+
# a tool with no entry declares no schema, which is `read_concept`, whose
|
|
14
|
+
# answer is markdown and has no object shape to describe.
|
|
15
|
+
#
|
|
16
|
+
# Two rules hold them honest. `required` is the **intersection** across a
|
|
17
|
+
# tool's variants, never one variant's keys — `lint` answers two shapes and
|
|
18
|
+
# `graph` three, so only `bundle` (and `view`) survive. And the row arrays
|
|
19
|
+
# are typed as arrays of objects but their *items* are left open, because
|
|
20
|
+
# `fields` projects a row down to what the caller asked for; naming the row
|
|
21
|
+
# properties here would make a projection a schema violation.
|
|
22
|
+
#
|
|
23
|
+
# `test/integration/output_schema_test.rb` runs every variant with the
|
|
24
|
+
# SDK's result validation switched on, so drift fails there rather than in
|
|
25
|
+
# somebody's host.
|
|
26
|
+
module OutputSchemas
|
|
27
|
+
ROWS = { type: "array", items: { type: "object" } }.freeze
|
|
28
|
+
COUNT = { type: "integer" }.freeze
|
|
29
|
+
SLUG = { type: "string" }.freeze
|
|
30
|
+
|
|
31
|
+
# `unparseable` rides along on any bundle-scoped view whose reader hit a
|
|
32
|
+
# file it could not parse, so it is optional everywhere and required
|
|
33
|
+
# nowhere.
|
|
34
|
+
UNPARSEABLE = { type: "integer" }.freeze
|
|
35
|
+
|
|
36
|
+
SCHEMAS = {
|
|
37
|
+
"list_bundles" => {
|
|
38
|
+
properties: {
|
|
39
|
+
backend: { type: "object" },
|
|
40
|
+
registry_source: { type: %w[string null] },
|
|
41
|
+
default: SLUG,
|
|
42
|
+
groups: { type: "array" },
|
|
43
|
+
total: COUNT,
|
|
44
|
+
bundles: ROWS
|
|
45
|
+
},
|
|
46
|
+
required: %w[backend total bundles]
|
|
47
|
+
},
|
|
48
|
+
"dirs" => {
|
|
49
|
+
properties: { bundle: SLUG, total: COUNT, dirs: ROWS, unparseable: UNPARSEABLE },
|
|
50
|
+
required: %w[bundle total dirs]
|
|
51
|
+
},
|
|
52
|
+
"index" => {
|
|
53
|
+
properties: { bundle: SLUG, total: COUNT, dirs: ROWS, unparseable: UNPARSEABLE },
|
|
54
|
+
required: %w[bundle total dirs]
|
|
55
|
+
},
|
|
56
|
+
"search" => {
|
|
57
|
+
properties: {
|
|
58
|
+
query: { type: "array", items: { type: "string" } },
|
|
59
|
+
# Resolved, not echoed — `fuzzy` picks the index without being
|
|
60
|
+
# asked — so it is always present and belongs in `required`.
|
|
61
|
+
engine: { type: "string", enum: %w[scan index] },
|
|
62
|
+
bundles: ROWS,
|
|
63
|
+
total: COUNT,
|
|
64
|
+
results: ROWS,
|
|
65
|
+
unparseable: UNPARSEABLE
|
|
66
|
+
},
|
|
67
|
+
required: %w[query engine bundles total results]
|
|
68
|
+
},
|
|
69
|
+
"catalog" => {
|
|
70
|
+
properties: { bundle: SLUG, total: COUNT, concepts: ROWS, unparseable: UNPARSEABLE },
|
|
71
|
+
required: %w[bundle total concepts]
|
|
72
|
+
},
|
|
73
|
+
# `total` is entries across every log file and `files` how many files
|
|
74
|
+
# they came from — two different counts, both named, because one
|
|
75
|
+
# standing for the other is what let an unbounded 119 KB answer read
|
|
76
|
+
# as bounded.
|
|
77
|
+
"log" => {
|
|
78
|
+
properties: { bundle: SLUG, total: COUNT, files: COUNT, logs: ROWS },
|
|
79
|
+
required: %w[bundle total files logs]
|
|
80
|
+
},
|
|
81
|
+
"validate" => {
|
|
82
|
+
properties: {
|
|
83
|
+
bundle: SLUG,
|
|
84
|
+
conformant: { type: "boolean" },
|
|
85
|
+
errors: ROWS,
|
|
86
|
+
warnings: ROWS,
|
|
87
|
+
counts: { type: "object" }
|
|
88
|
+
},
|
|
89
|
+
required: %w[bundle conformant errors warnings counts]
|
|
90
|
+
},
|
|
91
|
+
# Two shapes: the findings report, and `group: "folder"`'s file
|
|
92
|
+
# listing. Only what both carry is required.
|
|
93
|
+
"lint" => {
|
|
94
|
+
properties: {
|
|
95
|
+
bundle: SLUG,
|
|
96
|
+
total: COUNT,
|
|
97
|
+
healthy: { type: "boolean" },
|
|
98
|
+
stats: { type: "object" },
|
|
99
|
+
findings: ROWS,
|
|
100
|
+
group: { type: "string" },
|
|
101
|
+
files: ROWS,
|
|
102
|
+
unparseable: UNPARSEABLE
|
|
103
|
+
},
|
|
104
|
+
required: %w[bundle total]
|
|
105
|
+
},
|
|
106
|
+
# Three shapes — minimal, hubs, traffic — sharing only their identity.
|
|
107
|
+
"graph" => {
|
|
108
|
+
properties: {
|
|
109
|
+
bundle: SLUG,
|
|
110
|
+
view: { type: "string" },
|
|
111
|
+
total_nodes: COUNT,
|
|
112
|
+
total_edges: COUNT,
|
|
113
|
+
nodes: ROWS,
|
|
114
|
+
edges: ROWS,
|
|
115
|
+
types: { type: "object" },
|
|
116
|
+
tags: { type: "object" },
|
|
117
|
+
total: COUNT,
|
|
118
|
+
hubs: ROWS,
|
|
119
|
+
cut: COUNT,
|
|
120
|
+
total_arcs: COUNT,
|
|
121
|
+
dirs: ROWS,
|
|
122
|
+
arcs: ROWS,
|
|
123
|
+
unparseable: UNPARSEABLE
|
|
124
|
+
},
|
|
125
|
+
required: %w[bundle view]
|
|
126
|
+
}
|
|
127
|
+
}.freeze
|
|
128
|
+
|
|
129
|
+
def self.[](name)
|
|
130
|
+
SCHEMAS[name.to_s]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Prompt: consume — use a bundle as context
|
|
2
|
+
|
|
3
|
+
1. **Orient first.** `list_bundles` names every bundle this server holds and
|
|
4
|
+
the slug each answers to. `dirs` gives a bundle's shape; `index` with `dir`
|
|
5
|
+
opens the branch you want — its authored index body, rollups and concept
|
|
6
|
+
listing — and `log` gives recent history. Then follow links only into the
|
|
7
|
+
concepts the task needs, one `read_concept` at a time. For a *pointed
|
|
8
|
+
question* rather than broad context, switch to the okf-search prompt's
|
|
9
|
+
discipline: map → `search` → only the winning bodies. For a large bundle,
|
|
10
|
+
`graph` with view `"minimal"` gives the whole link structure at once, so
|
|
11
|
+
you can plan a traversal without reading a single body.
|
|
12
|
+
2. Treat broken links as not-yet-written knowledge, not errors.
|
|
13
|
+
3. **Surface what you learn.** This server never writes. If you learn
|
|
14
|
+
something durable while working — a fact the bundle lacks, a link it is
|
|
15
|
+
missing, a concept that no longer matches reality — say so instead of
|
|
16
|
+
letting it evaporate: recording it belongs to the bundle's maintainers
|
|
17
|
+
through the okf skill and CLI, and what keeps a bundle alive is being told.
|