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.
@@ -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
@@ -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.