okf-mcp 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 16fa224a5cb1c83ae561094458afe95726a3f04c94a35e56db94b260a2a213bc
4
- data.tar.gz: 06027d6762f7f35510d22fc124ecd8e473249bb492baea09238fcd23dafd01a1
3
+ metadata.gz: 6f19f69227417cc91ee456e6fca9f517ae1d8f4d571bb7423c7d76d4e38df238
4
+ data.tar.gz: dcd1b33fb97cb5eb47bbf01681b2b291d26b035c33d9dc5585422edd3f00e165
5
5
  SHA512:
6
- metadata.gz: 7e37dd09203d6ace0e2a6394a3efdc8551bc3f74bd0e53c7e30c27795cbb7f9fa3975bb9d5697bc4afdcabbef075300386362a0f4f7f79d7ec863beb638f31bb
7
- data.tar.gz: 4c82c6fdf6fe68a04cc109d4ed3187606f6e276cdfc0cbf0368680d36cd2e2f67c05062304e3698cd473c739de5ac7089c2856c612c742718f5a9e0c251636e6
6
+ metadata.gz: 1821ef4c6684ec3ca30d3368f1c4ee07c308f24693278b38866e91f2ad1b12b8f742d45cc42c02d002c5f75bd9cd007e64aec155de439c1f2a47d9c12bedc0f8
7
+ data.tar.gz: 399fdd6c71fad193ce28256b5982d6de87fe547ac818aa4813a72981f8afe8733488f91a420c0232dc58221bcd29dd31f2a8fa318ffc584af521fd8d1d9d0279
data/CHANGELOG.md CHANGED
@@ -5,6 +5,84 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.0] - 2026-08-14
9
+
10
+ ### Added
11
+
12
+ - **`tags`, `types` and `stats` — the CLI's remaining read views, as tools.** `tags`
13
+ ships with the `by: "dir" | "type"` curation view — the kernel's
14
+ `Bundle#tag_groups`, extracted so the within-group-beside-total counting
15
+ has one home — and `stats` reads the kernel's new `Bundle#stats`, whose
16
+ `by_dir` keeps the honest zero a directory holding nothing directly
17
+ reports. `files` is deliberately not a tool: `index`'s per-directory
18
+ listing and `catalog`'s projection already carry its whole answer, and
19
+ tool-list weight is a real cost on hosts.
20
+ - **`lint` takes `today`** — the CLI's `--today`, in the same deliberately
21
+ narrow calendar-day grammar (a reinterpretable `20260101` or a
22
+ `2026-02-30` is refused by name), so an `expired` report is reproducible
23
+ and citable. The wall clock stays the default.
24
+ - **`fields`/`except` projection on `search`, and `except` on `catalog`** —
25
+ the row-key vocabulary is checked either way, so a typo is refused by
26
+ name even when a query matches nothing; the pair is mutually exclusive.
27
+ - **`references`, the eleventh tool** — the kernel's §6.3 inventory over MCP,
28
+ and the one lens that sees a bundle's non-markdown files: every
29
+ `references/` entry with the concepts citing it through the §6.2
30
+ path-valued fields, plus every pointer that resolves to nothing (a bare
31
+ `references/…` from a subdirectory is the classic miss; the dangling entry
32
+ names the leading-slash fix). Advisory like every read tool — dangling
33
+ pointers are data, never a tool error.
34
+ - **`graph`'s traffic view takes `cut`** — the CLI's `--cut N`, so a client
35
+ can widen past the fitted threshold or narrow below it. Outside
36
+ `view: "traffic"` it is refused: a half-honored argument is the
37
+ silent-wrong-answer shape.
38
+
39
+ ### Changed
40
+
41
+ - **`search` narrows by `status` and `trust`**, the two filters `catalog` and
42
+ `okf search` already took. An agent that learned the vocabulary from either
43
+ one and brought it here got an invalid-argument error. They resolve through
44
+ the catalog rather than the engine's rows — a search row carries what the
45
+ engine matched on, and the §5 families are not among it.
46
+
47
+ - **`lint`'s `stale_after` takes the kernel's cutoff grammar** — a
48
+ `YYYY-MM-DD` date or a full timestamp — rather than everything
49
+ `Date.iso8601` parses. `20260101` and `2026-W01-1` are now tool errors
50
+ naming the accepted shapes; a `generated.at`-shaped timestamp is accepted,
51
+ as before, and reduced to its date. They were accepted before
52
+ and silently reinterpreted — `okf lint --stale-after 20260101` exits 2, so
53
+ the shell and its own CLI answered differently about one bundle's staleness.
54
+
55
+ - **The okf floor must move to okf's v0.2 release number in the release that
56
+ ships this** — the shell now reads v0.2 surfaces (the trust catalog column,
57
+ `Concept.effective_status`/`fold_tier`, the linter's clock contract), none
58
+ of which exist in published okf 1.13.0; the gemspec carries the same
59
+ RELEASE OBLIGATION note, and the floor cannot move sooner because the
60
+ monorepo resolves against the path-sourced okf still versioned 1.13.0.
61
+ `rake release` now refuses while the floor still admits 1.13.0 (the guard
62
+ hangs off `release:guard_clean`, ahead of both pushes, and deliberately not
63
+ off `build` — `rake install` depends on that), so the obligation stops a
64
+ release rather than riding inside one.
65
+ - The catalog tool speaks v0.2: `CATALOG_FIELDS` matches the new row
66
+ (`timestamp` is gone — the vocabulary refuses it by name), `status` narrows
67
+ on the effective value through the kernel's own fold, and a `trust` filter
68
+ lands beside it. `lint` supplies the clock the `expired` check needs, the
69
+ way the CLI does, and the client-visible descriptions and prompts cite
70
+ v0.2's chapter numbers.
71
+
72
+ ### Fixed
73
+
74
+ - **`search`'s declared output schema now carries `skipped`** — the field the
75
+ tool emits when `"*"` forgives a vanished bundle. A host typing its
76
+ structured-content handling off `outputSchema` dropped it, and the SDK's
77
+ result validation (on in the test suite) failed a payload the tool
78
+ genuinely produces; slugs, not rows, and never required.
79
+ - **An out-of-range `--port` or unresolvable `--bind` is a usage error.** The
80
+ socket layer raises `SocketError` (Socket::ResolutionError on newer
81
+ Rubies), which the boot rescue did not name, so a typo came back as a
82
+ backtrace and exit 1 instead of the one readable line and exit 2 every
83
+ other boot failure earns.
84
+
85
+
8
86
  ## [1.0.0] - 2026-08-07
9
87
 
10
88
  The first functional release: an MCP server over the okf kernel, judged by
@@ -213,5 +291,6 @@ rather than pretending to be changes somebody could have seen.
213
291
 
214
292
  The name reservation on RubyGems: an empty gem, no functionality.
215
293
 
294
+ [1.1.0]: https://github.com/serradura/okf-gem/compare/okf-mcp/v1.0.0...okf-mcp/v1.1.0
216
295
  [1.0.0]: https://github.com/serradura/okf-gem/releases/tag/okf-mcp/v1.0.0
217
296
  [0.0.0]: https://rubygems.org/gems/okf-mcp/versions/0.0.0
data/README.md CHANGED
@@ -91,27 +91,38 @@ to pin the set instead.
91
91
 
92
92
  ## Tools
93
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`:
94
+ Fourteen read-only tools. Every list answer is bounded and names the full
95
+ count it was cut from — the rows the request matched, before any `limit`:
96
96
 
97
97
  | Tool | What it answers |
98
98
  |---|---|
99
99
  | `list_bundles` | what exists: slug, title, root, concept count, type/tag rollups, the default, the groups, the backend |
100
100
  | `dirs` | the shape — one row per directory with direct and subtree counts; **the first move** |
101
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 |
102
+ | `search` | pointed questions: ANDed terms, scored rows carrying the fields they matched, across several bundles at once; narrows by `type`, `dir`, `tag`, `status`, `trust`; `fields`/`except` project the rows |
103
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 |
104
+ | `catalog` | per-concept metadata with link degrees; the same five filters, paging, `fields`/`except` projection |
105
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 |
106
+ | `validate` | the spec §11 conformance verdict |
107
+ | `lint` | the curation-quality report; `group: "folder"` lists the unlinked files by folder; `today` pins the clock for a reproducible `expired` report |
108
+ | `graph` | the knowledge graph in three bounded views: minimal, hubs, traffic (`cut` widens or narrows the arcs) — never with bodies |
109
+ | `references` | the §6.3 inventory: every `references/` file — `.py` attesters and `.sql` computations included — with its citing concepts, and the pointers that resolve to nothing |
110
+ | `tags` | the tag index by count; `by: "dir"` or `"type"` regroups for vocabulary curation, within-group counts beside cross-bundle totals |
111
+ | `types` | the type index by count — how you learn what a producer meant by its open §4.1 vocabulary |
112
+ | `stats` | the sizing rollup: concepts, dirs, cross-links, distinct tags, and the by_type/by_dir/by_top_dir distributions |
109
113
 
110
114
  Each returns its JSON twice: as text, and as `structuredContent` against a
111
115
  declared `outputSchema`, so a host consumes a result without parsing a blob and
112
116
  guessing at its shape. `read_concept` is the exception — markdown has no object
113
117
  shape to declare.
114
118
 
119
+ The `status` and `trust` filters answer with the §5 families' vocabulary
120
+ (`draft | stable | deprecated`; `unverified | machine-confirmed |
121
+ human-reviewed`, either spelling). On `search` they narrow through the
122
+ catalog rather than the engine's rows — a search row carries what the engine
123
+ matched on, and the §5 families are not among it — so a `trust` that narrows
124
+ `catalog` narrows `search` identically.
125
+
115
126
  ## Prompts
116
127
 
117
128
  Two prompts, the consuming pair: `okf-search` — retrieval as progressive
data/lib/okf/mcp/cli.rb CHANGED
@@ -62,7 +62,7 @@ module OKF
62
62
  server = Server.build(registry, engine: engine)
63
63
  announce(registry, engine)
64
64
  prepare_http(server) if @http
65
- rescue Error, OKF::Error, OptionParser::ParseError, SystemCallError => e
65
+ rescue Error, OKF::Error, OptionParser::ParseError, SystemCallError, SocketError => e
66
66
  say("okf-mcp: #{e.message}")
67
67
  say(USAGE)
68
68
  return 2
@@ -10,13 +10,18 @@ module OKF
10
10
  # reported an empty bundle root while `search` and `dirs` answered for it.
11
11
  # Two copies of a rule is two answers waiting to disagree, so there is one.
12
12
  #
13
- # There are genuinely **two** rules here, and the distinction is not drift:
13
+ # There are genuinely **two** rules here, and the distinction is not drift.
14
+ # Only one of them still lives in this file:
14
15
  #
15
- # #under_dir? filters *concepts*. A dir names itself and everything
16
- # beneath it; `.` is a prefix of nothing, so the root
16
+ # concepts are filtered by `Bundle::RowFilter`, the kernel's one
17
+ # catalog-row predicate a dir names itself and everything
18
+ # beneath it, and `.` is a prefix of nothing, so the root
17
19
  # selects only what lives directly in it (the CLI's `--dir`).
20
+ # This module's job there is #normalize_dir, the spelling
21
+ # fold the kernel deliberately does not do.
18
22
  # #within? scopes a *tree* of directory rows, where the root is the
19
- # ancestor of every row (`dirs`/`index` walking down).
23
+ # ancestor of every row (`dirs`/`index` walking down). The
24
+ # kernel has no view of a directory tree, so this one stays.
20
25
  module Filters
21
26
  module_function
22
27
 
@@ -48,13 +53,6 @@ module OKF
48
53
  folded.empty? || folded == "." ? "." : folded
49
54
  end
50
55
 
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
56
  # Tree scoping: the root is the ancestor of every row, so `.` selects
59
57
  # the whole tree rather than only what sits directly in it.
60
58
  def within?(entry_dir, base)
@@ -10,7 +10,7 @@ module OKF
10
10
  # deliberately no disk cache here — the process is the cache; disk-side
11
11
  # state is okf-sqlite3's territory.
12
12
  class MemoryBackend
13
- FILTER_KEYS = %i[type tag dir status].freeze
13
+ FILTER_KEYS = %i[type tag dir status trust].freeze
14
14
 
15
15
  # How many prepared corpora to hold at once, most-recently-used first.
16
16
  # It used to be unbounded, keyed on the queried subset — a long-lived
@@ -168,17 +168,21 @@ module OKF
168
168
  end
169
169
  end
170
170
 
171
+ # The kernel's one row predicate (Bundle::RowFilter), not a local copy:
172
+ # this seam diverged three recorded times, and the fix each time was to
173
+ # share the rule. This layer keeps only its own argument spelling —
174
+ # blanks mean "no filter" (an MCP client filling every optional property
175
+ # with "" is routine), and ""/"/" fold onto the root before the shared
176
+ # dir rule sees them.
171
177
  def matches?(row, filters)
172
- FILTER_KEYS.all? do |key|
178
+ wants = {}
179
+ FILTER_KEYS.each do |key|
173
180
  wanted = filters[key]
174
- next true if OKF.blank?(wanted)
181
+ next if OKF.blank?(wanted)
175
182
 
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
183
+ wants[key] = key == :dir ? Filters.normalize_dir(wanted) : wanted
181
184
  end
185
+ Bundle::RowFilter.matches?(row, **wants)
182
186
  end
183
187
 
184
188
  # What the residency cache watches: every markdown file with its mtime
@@ -62,7 +62,12 @@ module OKF
62
62
  bundles: ROWS,
63
63
  total: COUNT,
64
64
  results: ROWS,
65
- unparseable: UNPARSEABLE
65
+ unparseable: UNPARSEABLE,
66
+ # Present only when "*" or a group forgave a vanished bundle —
67
+ # conditional, so never required; slugs, not rows. Omitting it
68
+ # entirely is how a real field failed result validation the first
69
+ # time it appeared.
70
+ skipped: { type: "array", items: SLUG }
66
71
  },
67
72
  required: %w[query engine bundles total results]
68
73
  },
@@ -70,6 +75,13 @@ module OKF
70
75
  properties: { bundle: SLUG, total: COUNT, concepts: ROWS, unparseable: UNPARSEABLE },
71
76
  required: %w[bundle total concepts]
72
77
  },
78
+ # `dangling` is always present (empty when nothing misses): an absent
79
+ # list would read as "not checked", which is the one thing an
80
+ # inventory must never say by accident.
81
+ "references" => {
82
+ properties: { bundle: SLUG, total: COUNT, references: ROWS, dangling: ROWS, unparseable: UNPARSEABLE },
83
+ required: %w[bundle total references dangling]
84
+ },
73
85
  # `total` is entries across every log file and `files` how many files
74
86
  # they came from — two different counts, both named, because one
75
87
  # standing for the other is what let an unbounded 119 KB answer read
@@ -78,6 +90,30 @@ module OKF
78
90
  properties: { bundle: SLUG, total: COUNT, files: COUNT, logs: ROWS },
79
91
  required: %w[bundle total files logs]
80
92
  },
93
+ # Two shapes, like lint's: the plain inverted index (`tags`) and the
94
+ # `by` regrouping (`groups`). Only what both carry is required.
95
+ "tags" => {
96
+ properties: {
97
+ bundle: SLUG, total: COUNT, tags: ROWS,
98
+ by: { type: "string" }, groups: ROWS,
99
+ unparseable: UNPARSEABLE
100
+ },
101
+ required: %w[bundle total]
102
+ },
103
+ "types" => {
104
+ properties: { bundle: SLUG, total: COUNT, types: ROWS, unparseable: UNPARSEABLE },
105
+ required: %w[bundle total types]
106
+ },
107
+ "stats" => {
108
+ properties: {
109
+ bundle: SLUG,
110
+ concepts: COUNT, dirs: COUNT, top_dirs: COUNT, concept_types: COUNT,
111
+ cross_links: COUNT, distinct_tags: COUNT,
112
+ by_type: { type: "object" }, by_dir: { type: "object" }, by_top_dir: { type: "object" },
113
+ unparseable: UNPARSEABLE
114
+ },
115
+ required: %w[bundle concepts dirs top_dirs concept_types cross_links distinct_tags by_type by_dir by_top_dir]
116
+ },
81
117
  "validate" => {
82
118
  properties: {
83
119
  bundle: SLUG,
@@ -1,7 +1,7 @@
1
1
  # Prompt: search — retrieve knowledge without paying for the whole bundle
2
2
 
3
3
  Retrieval matters as much as curation: a bundle nobody can query cheaply is
4
- dead weight. The discipline is progressive disclosure (spec §6): every step
4
+ dead weight. The discipline is progressive disclosure (spec §8): every step
5
5
  pays a few hundred bytes to decide what the next step reads, and full bodies
6
6
  are read last, and only the winners.
7
7
 
@@ -49,7 +49,7 @@ are read last, and only the winners.
49
49
  an id home.
50
50
  4. **Read only the winners.** `read_concept` takes a result row's exact `id`
51
51
  and returns the file verbatim — that concept, not its folder, never the
52
- whole tree. Follow its links (§5) one hop at a time; check `log` when
52
+ whole tree. Follow its links (§6) one hop at a time; check `log` when
53
53
  freshness matters.
54
54
  5. **Answer, then surface the friction.** Cite the concept ids you used. This
55
55
  server never writes — if the answer was missing, stale, or needlessly hard
@@ -33,6 +33,16 @@ module OKF
33
33
  ROLLUP_LIMIT = 25
34
34
 
35
35
  SEARCH_LIMIT = 20
36
+
37
+ # The two concept dimensions `tags` regroups by (§4.1's `type`, the
38
+ # file's directory) — the same pair the CLI's `--by` offers, minus its
39
+ # deprecated `area` spelling, which a new surface does not inherit.
40
+ TAG_DIMENSIONS = %w[type dir].freeze
41
+
42
+ # The keys a search result row carries — the vocabulary `fields`/`except`
43
+ # project against, declared so a typo is refused by name even when a
44
+ # query happens to match nothing.
45
+ SEARCH_ROW_FIELDS = %w[bundle id title type tags matched score snippet].freeze
36
46
  CATALOG_LIMIT = 200
37
47
 
38
48
  # Date-grouped entries returned per log.md. A log is the one file in a
@@ -44,7 +54,7 @@ module OKF
44
54
  # for more.
45
55
  LOG_LIMIT = 3
46
56
 
47
- # A §7 log is "a flat list of date-grouped entries, newest first", so a
57
+ # A §9 log is "a flat list of date-grouped entries, newest first", so a
48
58
  # `## ` heading at column 0 is the entry boundary. Split kept here rather
49
59
  # than pushed into the kernel because bounding for a context window is
50
60
  # this surface's problem alone: the graph page's Log panel wants the
@@ -63,7 +73,8 @@ module OKF
63
73
  LOG_BUDGET = 4_500
64
74
 
65
75
  # The catalog row, the projection vocabulary `fields` selects from.
66
- CATALOG_FIELDS = %w[id title type description tags timestamp status backlog_ref dir top_dir links_out links_in].freeze
76
+ CATALOG_FIELDS = %w[id title type description tags generated_at generated_by generated trust status
77
+ stale_after sources backlog_ref dir top_dir links_out links_in].freeze
67
78
 
68
79
  GRAPH_VIEWS = %w[minimal hubs traffic].freeze
69
80
  LINT_GROUPS = %w[check folder].freeze
@@ -228,10 +239,98 @@ module OKF
228
239
  log_tool(context),
229
240
  validate_tool(context),
230
241
  lint_tool(context),
231
- graph_tool(context)
242
+ graph_tool(context),
243
+ references_tool(context),
244
+ tags_tool(context),
245
+ types_tool(context),
246
+ stats_tool(context)
232
247
  ]
233
248
  end
234
249
 
250
+ # tags' plain view is the inverted index; `by` is the curation view —
251
+ # the kernel's Bundle#tag_groups, so a within-group count beside its
252
+ # cross-bundle total reads identically here and on the CLI.
253
+ def tags_tool(context)
254
+ define_tool(
255
+ name: "tags",
256
+ description: "The tag index: every tag with its count and concepts, ordered by count. " \
257
+ "`by: \"dir\"` or `by: \"type\"` regroups per concept dimension for vocabulary " \
258
+ "curation — each tag then carries `count` (within the group) beside `total` " \
259
+ "(across the bundle), so a tag local to one group and one scattered across " \
260
+ "several read differently at a glance.",
261
+ input_schema: {
262
+ properties: {
263
+ bundle: { type: "string", description: "A bundle slug from list_bundles." },
264
+ by: { type: "string", enum: TAG_DIMENSIONS,
265
+ description: "Regroup per concept dimension: #{TAG_DIMENSIONS.join(" | ")}." }
266
+ },
267
+ required: [ "bundle" ]
268
+ }
269
+ ) do |bundle:, by: nil|
270
+ folder = context.folder(bundle)
271
+ if by
272
+ groups = folder.tag_groups(by: by.to_sym)
273
+ distinct = groups.flat_map { |_, rows| rows.map { |row| row[:tag] } }.uniq.length
274
+ rows = groups.map { |key, tag_rows| { by.to_sym => key, count: tag_rows.length, tags: tag_rows } }
275
+ respond_json(with_unparseable(folder,
276
+ bundle: slug_of(context, bundle), total: distinct, by: by, groups: rows))
277
+ else
278
+ rows = inverted_rows(folder.graph(minimal: true).tag_index, :tag)
279
+ respond_json(with_unparseable(folder,
280
+ bundle: slug_of(context, bundle), total: rows.length, tags: rows))
281
+ end
282
+ end
283
+ end
284
+
285
+ def types_tool(context)
286
+ define_tool(
287
+ name: "types",
288
+ description: "The type index: every type with its count and concepts, ordered by count. " \
289
+ "§4.1's vocabulary is open — this is how you learn what a bundle's producer " \
290
+ "meant by its types before filtering the catalog on one.",
291
+ input_schema: {
292
+ properties: {
293
+ bundle: { type: "string", description: "A bundle slug from list_bundles." }
294
+ },
295
+ required: [ "bundle" ]
296
+ }
297
+ ) do |bundle:|
298
+ folder = context.folder(bundle)
299
+ rows = inverted_rows(folder.graph(minimal: true).type_index, :type)
300
+ respond_json(with_unparseable(folder,
301
+ bundle: slug_of(context, bundle), total: rows.length, types: rows))
302
+ end
303
+ end
304
+
305
+ # The sizing rollup — the kernel's Bundle#stats, whose by_dir keeps the
306
+ # zero a directory holding nothing directly honestly reports. The two
307
+ # dir keys deliberately speak two languages: by_dir is the disk,
308
+ # by_top_dir rolls up the id (the recorded identity-vs-physical split).
309
+ def stats_tool(context)
310
+ define_tool(
311
+ name: "stats",
312
+ description: "Bundle rollups in one answer: concepts, dirs, types, cross-links, distinct " \
313
+ "tags, and the by_type/by_dir/by_top_dir distributions — \"how big is what I am " \
314
+ "about to read\". by_dir counts the file's directory (a dir holding nothing " \
315
+ "directly reports 0); by_top_dir rolls up the concept id's first segment.",
316
+ input_schema: {
317
+ properties: {
318
+ bundle: { type: "string", description: "A bundle slug from list_bundles." }
319
+ },
320
+ required: [ "bundle" ]
321
+ }
322
+ ) do |bundle:|
323
+ folder = context.folder(bundle)
324
+ rollup = folder.stats
325
+ respond_json(with_unparseable(folder,
326
+ bundle: slug_of(context, bundle),
327
+ concepts: rollup[:concepts], dirs: rollup[:dirs], top_dirs: rollup[:top_dirs],
328
+ concept_types: rollup[:types], cross_links: rollup[:cross_links],
329
+ distinct_tags: rollup[:tags],
330
+ by_type: rollup[:by_type], by_dir: rollup[:by_dir], by_top_dir: rollup[:by_top_dir]))
331
+ end
332
+ end
333
+
235
334
  # The consuming prompts, and only those — this gem's own text, written
236
335
  # against the tools above rather than the CLI. The skill's playbooks
237
336
  # were served here verbatim once, all eight, on the argument that a
@@ -392,7 +491,15 @@ module OKF
392
491
  type: { type: "string", description: "Only concepts of this type." },
393
492
  dir: { type: "string", description: "Only concepts in this directory or below it (\".\" for the bundle root)." },
394
493
  tag: { type: "string", description: "Only concepts carrying this tag." },
395
- limit: { type: "integer", minimum: 1, description: "Maximum rows to return (default #{SEARCH_LIMIT}); `total` is the count before the cut." }
494
+ status: { type: "string",
495
+ description: "Only concepts at this effective lifecycle status " \
496
+ "(absent reads stable — draft | stable | deprecated, or any declared value)." },
497
+ trust: { type: "string", description: "Only concepts at this trust tier (unverified | machine-confirmed | human-reviewed; either spelling)." },
498
+ limit: { type: "integer", minimum: 1, description: "Maximum rows to return (default #{SEARCH_LIMIT}); `total` is the count before the cut." },
499
+ fields: { type: "array", items: { type: "string" },
500
+ description: "Emit only these result-row keys (#{SEARCH_ROW_FIELDS.join(", ")})." },
501
+ except: { type: "array", items: { type: "string" },
502
+ description: "Emit every result-row key but these (mutually exclusive with fields)." }
396
503
  },
397
504
  required: [ "terms" ]
398
505
  }
@@ -440,7 +547,8 @@ module OKF
440
547
  name: "catalog",
441
548
  description: "Per-concept metadata for a whole bundle — #{CATALOG_FIELDS.join(", ")} — " \
442
549
  "filterable by type, dir (prefix: a dir names itself and everything beneath " \
443
- "it), tag, and status. Use it for inventories and rollups; use search when you " \
550
+ "it), tag, status (effective: absent reads stable) and trust tier. Use it for " \
551
+ "inventories and rollups; use search when you " \
444
552
  "have terms. Returns `limit` rows (default #{CATALOG_LIMIT}) from `offset`; `total` is the " \
445
553
  "full count before slicing. `fields` projects each row down to the named keys.",
446
554
  input_schema: {
@@ -449,23 +557,27 @@ module OKF
449
557
  type: { type: "string", description: "Only concepts of this type." },
450
558
  dir: { type: "string", description: "Only concepts in this directory or below it (\".\" for the bundle root)." },
451
559
  tag: { type: "string", description: "Only concepts carrying this tag." },
452
- status: { type: "string", description: "Only concepts with this frontmatter status." },
560
+ status: { type: "string",
561
+ description: "Only concepts at this effective lifecycle status " \
562
+ "(absent reads stable — draft | stable | deprecated, or any declared value)." },
563
+ trust: { type: "string", description: "Only concepts at this trust tier (unverified | machine-confirmed | human-reviewed; either spelling)." },
453
564
  fields: { type: "array", items: { type: "string" }, description: "Emit only these row keys (#{CATALOG_FIELDS.join(", ")})." },
565
+ except: { type: "array", items: { type: "string" }, description: "Emit every row key but these (mutually exclusive with fields)." },
454
566
  limit: { type: "integer", minimum: 1, description: "Maximum concepts to return (default #{CATALOG_LIMIT})." },
455
567
  offset: { type: "integer", minimum: 0, description: "Skip this many concepts first (default 0)." }
456
568
  },
457
569
  required: [ "bundle" ]
458
570
  }
459
- ) do |bundle:, type: nil, dir: nil, tag: nil, status: nil, fields: nil, limit: CATALOG_LIMIT, offset: 0|
571
+ ) do |bundle:, type: nil, dir: nil, tag: nil, status: nil, trust: nil, fields: nil, except: nil, limit: CATALOG_LIMIT, offset: 0|
460
572
  root = context.root!(bundle)
461
- projection = check_fields(fields)
573
+ keep, drop = check_projection(fields, except)
462
574
  # One folder, held: the `dir` check and the unparseable count both
463
575
  # want it, and asking the residency layer twice walks the tree twice.
464
576
  folder = context.memory.folder(root)
465
577
  check_dir!(context, [ [ slug_of(context, bundle), root ] ], dir)
466
- rows = context.engine.catalog(root, { type: type, dir: dir, tag: tag, status: status })
578
+ rows = context.engine.catalog(root, { type: type, dir: dir, tag: tag, status: status, trust: trust })
467
579
  sliced = rows[offset, limit] || []
468
- sliced = sliced.map { |row| row.select { |key, _| projection.include?(key.to_s) } } if projection
580
+ sliced = project_rows(sliced, keep, drop)
469
581
  respond_json(with_unparseable(folder,
470
582
  bundle: slug_of(context, bundle), total: rows.length, concepts: sliced))
471
583
  end
@@ -499,7 +611,7 @@ module OKF
499
611
  def validate_tool(context)
500
612
  define_tool(
501
613
  name: "validate",
502
- description: "The spec §9 conformance verdict: `conformant` (hard errors empty), every error " \
614
+ description: "The spec §11 conformance verdict: `conformant` (hard errors empty), every error " \
503
615
  "and soft warning with its file and why — unopenable files included. Read-only: " \
504
616
  "you may flag what it finds; fixing it belongs to the okf skill and CLI. " \
505
617
  "Curation quality is lint's question, kept deliberately separate.",
@@ -525,10 +637,11 @@ module OKF
525
637
  define_tool(
526
638
  name: "lint",
527
639
  description: "The curation-quality report — reachability, backlog, completeness, freshness, " \
528
- "provenance, hygiene — as warnings and infos that never reject (conformance is " \
529
- "validate's question). `only`/`except` select by check id " \
530
- "(#{Bundle::Linter::CHECKS.join(", ")}). `stale_after` turns on freshness: a " \
531
- "duration like 90d or 12w, or an ISO date. `group: \"folder\"` answers \"which " \
640
+ "provenance, attestation, migration, hygiene — as warnings and infos that never " \
641
+ "reject (conformance is validate's question). `only`/`except` select by check id " \
642
+ "(#{Bundle::Linter::CHECKS.join(", ")}). `stale_after` turns on the age cutoff: a " \
643
+ "duration like 90d or 12w, or an ISO date (declared expiries — `expired` report " \
644
+ "out of the box). `group: \"folder\"` answers \"which " \
532
645
  "files float in the graph?\" instead — the unlinked concepts grouped by folder. " \
533
646
  "Noticing rot is yours; fixing it belongs to the okf skill and CLI.",
534
647
  input_schema: {
@@ -539,12 +652,15 @@ module OKF
539
652
  stale_after: { type: "string", description: "Flag concepts older than this: 90d, 12w, or an ISO date like 2026-01-01." },
540
653
  only: { type: "array", items: { type: "string" }, description: "Run only these checks (by check id)." },
541
654
  except: { type: "array", items: { type: "string" }, description: "Run every check but these (by check id)." },
542
- group: { type: "string", enum: LINT_GROUPS, description: "\"check\" (default) or \"folder\" — the unlinked files grouped by folder." }
655
+ group: { type: "string", enum: LINT_GROUPS, description: "\"check\" (default) or \"folder\" — the unlinked files grouped by folder." },
656
+ today: { type: "string",
657
+ description: "The calendar day `expired` compares stale_after against, YYYY-MM-DD " \
658
+ "(default: today) — pin it for a reproducible, citable report." }
543
659
  },
544
660
  required: [ "bundle" ]
545
661
  }
546
- ) do |bundle:, min_body: nil, stale_after: nil, only: nil, except: nil, group: "check"|
547
- lint_call(context, bundle, min_body, stale_after, only, except, group)
662
+ ) do |bundle:, min_body: nil, stale_after: nil, only: nil, except: nil, group: "check", today: nil|
663
+ lint_call(context, bundle, min_body, stale_after, only, except, group, today)
548
664
  end
549
665
  end
550
666
 
@@ -560,12 +676,40 @@ module OKF
560
676
  input_schema: {
561
677
  properties: {
562
678
  bundle: { type: "string", description: "A bundle slug from list_bundles." },
563
- view: { type: "string", enum: GRAPH_VIEWS, description: "One of #{GRAPH_VIEWS.join(", ")} (default minimal)." }
679
+ view: { type: "string", enum: GRAPH_VIEWS, description: "One of #{GRAPH_VIEWS.join(", ")} (default minimal)." },
680
+ cut: { type: "integer", minimum: 1,
681
+ description: "traffic only: least arc weight to draw (default: fitted to the bundle)." }
682
+ },
683
+ required: [ "bundle" ]
684
+ }
685
+ ) do |bundle:, view: "minimal", cut: nil|
686
+ graph_call(context, bundle, view, cut)
687
+ end
688
+ end
689
+
690
+ # The kernel's §6.3 inventory as a tool: the one lens that sees the
691
+ # non-markdown files a bundle carries. Advisory like every read here —
692
+ # a dangling pointer is data for the caller, never a tool error.
693
+ def references_tool(context)
694
+ define_tool(
695
+ name: "references",
696
+ description: "Inventory the bundle's references/ tree (§6.3): every file — the .py attesters " \
697
+ "and .sql computations no other tool can see included — with the concepts citing " \
698
+ "each through the §6.2 path-valued fields (resource, sources[].resource, " \
699
+ "computation, executor.resource, attester.resource), plus every pointer into " \
700
+ "references/ that resolves to nothing. A bare `references/…` written from a " \
701
+ "subdirectory is the classic miss, and the dangling entry names the " \
702
+ "leading-slash fix. Check it before trusting an attested computation's contract.",
703
+ input_schema: {
704
+ properties: {
705
+ bundle: { type: "string", description: "A bundle slug from list_bundles." }
564
706
  },
565
707
  required: [ "bundle" ]
566
708
  }
567
- ) do |bundle:, view: "minimal"|
568
- graph_call(context, bundle, view)
709
+ ) do |bundle:|
710
+ refs = context.folder(bundle).references
711
+ respond_json(bundle: slug_of(context, bundle), total: refs.entries.length,
712
+ references: refs.entries, dangling: refs.dangling)
569
713
  end
570
714
  end
571
715
 
@@ -588,6 +732,8 @@ module OKF
588
732
  check_dir!(context, pairs, options[:dir])
589
733
  rows = engine_rows(context, pairs, terms, fields, regexp, fuzzy, engine)
590
734
  rows = filter_rows(rows, options)
735
+ rows = narrow_by_catalog(rows, pairs, context, options)
736
+ keep, drop = check_projection(options[:fields], options[:except], allowed: SEARCH_ROW_FIELDS)
591
737
  limit = options[:limit] || SEARCH_LIMIT
592
738
 
593
739
  payload = {
@@ -599,12 +745,12 @@ module OKF
599
745
  # difference decides whether a miss means "absent" or "the
600
746
  # tokenizer shattered the identifier".
601
747
  engine: fuzzy || engine.to_s == "index" ? "index" : "scan",
602
- # §9 best-effort, surfaced per bundle: a corpus built from a folder
748
+ # §11 best-effort, surfaced per bundle: a corpus built from a folder
603
749
  # that skipped unusable files must say so, or a term living only in
604
750
  # an unreadable file reads back as "this bundle does not mention it".
605
751
  bundles: pairs.map { |slug, root| bundle_head_row(context, slug, root) },
606
752
  total: rows.length,
607
- results: rows.first(limit).map { |row| search_row(row, pairs) }
753
+ results: project_rows(rows.first(limit).map { |row| search_row(row, pairs) }, keep, drop)
608
754
  }
609
755
  payload[:skipped] = skipped unless skipped.empty?
610
756
  respond_json(payload)
@@ -690,13 +836,38 @@ module OKF
690
836
  # MCP client that fills every declared optional property with an empty
691
837
  # default (routine model behaviour) was handing `search` a filter that
692
838
  # matched nothing — while `catalog`, one tool over, read the same ""
693
- # as "no filter" and answered for the whole bundle.
839
+ # as "no filter" and answered for the whole bundle. The row rules
840
+ # themselves are the kernel's one predicate (Bundle::RowFilter).
694
841
  def filter_rows(rows, options)
695
- rows.select do |row|
696
- (OKF.blank?(options[:type]) || Filters.fold(row[:type]) == Filters.fold(options[:type])) &&
697
- (OKF.blank?(options[:dir]) || Filters.under_dir?(row[:dir], options[:dir])) &&
698
- (OKF.blank?(options[:tag]) || Array(row[:tags]).any? { |tag| Filters.fold(tag) == Filters.fold(options[:tag]) })
842
+ wants = {}
843
+ %i[type dir tag].each do |key|
844
+ wanted = options[key]
845
+ next if OKF.blank?(wanted)
846
+
847
+ wants[key] = key == :dir ? Filters.normalize_dir(wanted) : wanted
699
848
  end
849
+ rows.select { |row| Bundle::RowFilter.matches?(row, **wants) }
850
+ end
851
+
852
+ # `status` and `trust` narrow through the catalog rather than through
853
+ # #filter_rows, because a search row carries what the engine matched on
854
+ # and the §5 families are not among it — handed to RowFilter they would
855
+ # read as absent and match nothing, which is worse than not offering
856
+ # the filter. So they resolve the way `okf search --status/--trust`
857
+ # resolves them: ask the catalog which ids qualify, per bundle, and
858
+ # keep the rows that survive. Same predicate underneath, so a `trust`
859
+ # that narrows `catalog` narrows `search` identically.
860
+ def narrow_by_catalog(rows, pairs, context, options)
861
+ wants = {}
862
+ %i[status trust].each do |key|
863
+ wants[key] = options[key] unless OKF.blank?(options[key])
864
+ end
865
+ return rows if wants.empty?
866
+
867
+ allowed = {}
868
+ pairs.each { |slug, root| allowed[slug] = context.engine.catalog(root, wants).to_set { |row| row[:id] } }
869
+ only = pairs.length == 1 ? pairs.first.first : nil
870
+ rows.select { |row| allowed[row[:slug] || only]&.include?(row[:id]) }
700
871
  end
701
872
 
702
873
  # The protocol row: `bundle` leads (identity first), the head already
@@ -715,7 +886,7 @@ module OKF
715
886
  }
716
887
  end
717
888
 
718
- def lint_call(context, bundle, min_body, stale_after, only, except, group)
889
+ def lint_call(context, bundle, min_body, stale_after, only, except, group, today = nil)
719
890
  folder = context.folder(bundle)
720
891
  if group == "folder"
721
892
  # The folder lens *is* the unlinked check, so the check-selection
@@ -723,7 +894,7 @@ module OKF
723
894
  # while silently dropping them let a caller read the full listing
724
895
  # as though its filter had been applied — and skipped the check-id
725
896
  # vocabulary check, so a typo'd id passed here and errored there.
726
- offered = { only: only, except: except, min_body: min_body, stale_after: stale_after }
897
+ offered = { only: only, except: except, min_body: min_body, stale_after: stale_after, today: today }
727
898
  given = offered.reject { |_, value| OKF.blank?(value) }.keys
728
899
  unless given.empty?
729
900
  raise Error, "group \"folder\" is the unlinked lens and takes no #{given.join("/")} — " \
@@ -744,6 +915,14 @@ module OKF
744
915
  opts = checks
745
916
  opts[:min_body] = min_body unless min_body.nil?
746
917
  opts[:stale_before] = parse_stale(stale_after) unless OKF.blank?(stale_after)
918
+ # The shell owns clock resolution (see parse_stale): the pure linter
919
+ # runs no `expired` check unless handed a day, the CLI passes
920
+ # Date.today, and this layer must too — without it, a bundle full of
921
+ # passed expiries linted healthy through MCP while the CLI reported
922
+ # every one. `today` pins the day instead — the CLI's --today, the
923
+ # reproducible-report lever — in the same deliberately narrow
924
+ # calendar-day grammar.
925
+ opts[:today] = parse_today(today)
747
926
  report = folder.lint(**opts)
748
927
  respond_json(
749
928
  bundle: slug_of(context, bundle),
@@ -773,7 +952,12 @@ module OKF
773
952
  with_unparseable(context.memory.folder(root), row)
774
953
  end
775
954
 
776
- def graph_call(context, bundle, view)
955
+ def graph_call(context, bundle, view, cut = nil)
956
+ # A cut outside traffic would be accepted and ignored — the
957
+ # half-honored argument is the silent-wrong-answer shape, so it
958
+ # refuses instead. The schema already floors it at 1.
959
+ raise Error, "cut applies to view: \"traffic\" only (got view: #{view.inspect})" if cut && view != "traffic"
960
+
777
961
  folder = context.folder(bundle)
778
962
  payload = with_unparseable(folder, bundle: slug_of(context, bundle), view: view)
779
963
  case view
@@ -785,7 +969,7 @@ module OKF
785
969
  hubs = folder.hubs
786
970
  payload.merge!(total: hubs.length, hubs: hubs)
787
971
  when "traffic"
788
- payload.merge!(traffic_payload(folder.skeleton))
972
+ payload.merge!(traffic_payload(folder.skeleton, cut))
789
973
  end
790
974
  respond_json(payload)
791
975
  end
@@ -794,8 +978,8 @@ module OKF
794
978
  # arcs above the fitted cut — the CLI's `graph --traffic`, as data. The
795
979
  # cohesion is measured over every arc; the cut only decides which arcs
796
980
  # are worth returning.
797
- def traffic_payload(skeleton)
798
- cut = skeleton.suggested_cut
981
+ def traffic_payload(skeleton, cut = nil)
982
+ cut ||= skeleton.suggested_cut
799
983
  out = Hash.new(0)
800
984
  into = Hash.new(0)
801
985
  skeleton.arcs.each do |arc|
@@ -896,7 +1080,7 @@ module OKF
896
1080
  # ("# Runbooks Log") and costs a line.
897
1081
  #
898
1082
  # A log the split cannot divide is one indivisible entry, because that
899
- # is what content with no boundary is — §7 fixes no heading level, so
1083
+ # is what content with no boundary is — §9 fixes no heading level, so
900
1084
  # `###` date groups are conformant and `LOG_ENTRY` cannot see them.
901
1085
  # But a *bare title* is not content: a scaffolded "# Update Log" with
902
1086
  # no entries yet holds zero, and counting it as one told an agent
@@ -949,7 +1133,7 @@ module OKF
949
1133
  row
950
1134
  end
951
1135
 
952
- # §9 best-effort, surfaced: a payload built from a folder that skipped
1136
+ # §11 best-effort, surfaced: a payload built from a folder that skipped
953
1137
  # unusable files says so, and validate names each file and why.
954
1138
  def with_unparseable(folder, payload)
955
1139
  count = folder.bundle.unparseable.length
@@ -1002,15 +1186,83 @@ module OKF
1002
1186
  asked
1003
1187
  end
1004
1188
 
1189
+ # The fields/except pair, checked as one ask: mutually exclusive, both
1190
+ # against the same vocabulary, so a typo'd except is refused by name
1191
+ # exactly the way a typo'd fields always was.
1192
+ def check_projection(fields, except, allowed: CATALOG_FIELDS)
1193
+ if !OKF.blank?(fields) && !OKF.blank?(except)
1194
+ raise Error, "fields and except are mutually exclusive — name what to keep or what to drop, not both"
1195
+ end
1196
+
1197
+ [ check_asked(fields, allowed), check_asked(except, allowed) ]
1198
+ end
1199
+
1200
+ def check_asked(asked, allowed)
1201
+ return nil if OKF.blank?(asked)
1202
+
1203
+ names = Array(asked).map { |field| field.to_s.downcase }
1204
+ unknown = names - allowed
1205
+ raise Error, "unknown field(s): #{unknown.join(", ")} (row keys: #{allowed.join(", ")})" unless unknown.empty?
1206
+
1207
+ names
1208
+ end
1209
+
1210
+ # keep (allowlist) or drop (denylist) per row; both nil passes through.
1211
+ def project_rows(rows, keep, drop)
1212
+ return rows if keep.nil? && drop.nil?
1213
+
1214
+ rows.map do |row|
1215
+ keep ? row.select { |key, _| keep.include?(key.to_s) } : row.reject { |key, _| drop.include?(key.to_s) }
1216
+ end
1217
+ end
1218
+
1219
+ # An inverted index ({ value => [id, …] }) as rows ordered by count
1220
+ # then value — the same order the CLI's index views print.
1221
+ def inverted_rows(index, key)
1222
+ index.map { |value, ids| { key => value, count: ids.length, concepts: ids } }
1223
+ .sort_by { |row| [ -row[:count], row[key] ] }
1224
+ end
1225
+
1226
+ # The calendar day `expired` compares against — wall clock unless the
1227
+ # caller pins one. Not stale_after's grammar: this names a day, not a
1228
+ # moment, and both refuse the basic/week ISO spellings Date.iso8601
1229
+ # would silently reinterpret (20260101, 2026-W01-1) — plus the
1230
+ # digit-shaped day that never existed (2026-02-30).
1231
+ def parse_today(value)
1232
+ return Date.today if OKF.blank?(value)
1233
+
1234
+ text = value.to_s
1235
+ raise Error, today_error(text) unless text.match?(OKF::Concept::ISO_DATE)
1236
+
1237
+ begin
1238
+ Date.iso8601(text)
1239
+ rescue ArgumentError
1240
+ raise Error, today_error(text)
1241
+ end
1242
+ end
1243
+
1244
+ def today_error(text)
1245
+ "today takes a calendar day, YYYY-MM-DD — got #{text.inspect}"
1246
+ end
1247
+
1005
1248
  # A stale_after ask (90d, 12w, or an ISO date) into the absolute Time
1006
1249
  # the pure Linter compares against — the CLI's resolution, kept here in
1007
1250
  # the shell so the linter never reads the clock.
1008
1251
  def parse_stale(value)
1009
- if (match = value.to_s.match(/\A(\d+)([dw])\z/))
1252
+ text = value.to_s
1253
+ if (match = text.match(/\A(\d+)([dw])\z/))
1010
1254
  days = match[1].to_i * (match[2] == "w" ? 7 : 1)
1011
1255
  Time.now - (days * 86_400)
1256
+ elsif text.match?(Concept::ISO_CUTOFF)
1257
+ # The kernel's cutoff grammar, which is also `okf lint
1258
+ # --stale-after`'s: a date or a full timestamp (what a caller gets
1259
+ # by reading a concept's `generated.at`), never the basic or week
1260
+ # spellings Date.iso8601 would silently reinterpret — this shell
1261
+ # would otherwise answer about one bundle's staleness from a value
1262
+ # its own CLI exits 2 on.
1263
+ Date.iso8601(text).to_time
1012
1264
  else
1013
- Date.iso8601(value.to_s).to_time
1265
+ raise Error, "invalid stale_after #{value.inspect} — use 90d, 12w, or an ISO date like 2026-01-01"
1014
1266
  end
1015
1267
  rescue ArgumentError
1016
1268
  raise Error, "invalid stale_after #{value.inspect} — use 90d, 12w, or an ISO date like 2026-01-01"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OKF
4
4
  module MCP
5
- VERSION = "1.0.0"
5
+ VERSION = "1.1.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: okf-mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rodrigo Serradura
@@ -29,14 +29,20 @@ dependencies:
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '1.13'
32
+ version: '2.0'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '3'
33
36
  type: :runtime
34
37
  prerelease: false
35
38
  version_requirements: !ruby/object:Gem::Requirement
36
39
  requirements:
37
40
  - - ">="
38
41
  - !ruby/object:Gem::Version
39
- version: '1.13'
42
+ version: '2.0'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '3'
40
46
  description: |
41
47
  OKF::MCP is a thin Model Context Protocol shell over the okf kernel: it maps
42
48
  MCP tool calls onto okf's pure library API so any MCP-capable agent host can