okf-mcp 1.0.0 → 1.2.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.
@@ -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,101 @@ 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
+ title: "Tag index",
257
+ description: "The tag index: every tag with its count and concepts, ordered by count. " \
258
+ "`by: \"dir\"` or `by: \"type\"` regroups per concept dimension for vocabulary " \
259
+ "curation — each tag then carries `count` (within the group) beside `total` " \
260
+ "(across the bundle), so a tag local to one group and one scattered across " \
261
+ "several read differently at a glance.",
262
+ input_schema: {
263
+ properties: {
264
+ bundle: { type: "string", description: "A bundle slug from list_bundles." },
265
+ by: { type: "string", enum: TAG_DIMENSIONS,
266
+ description: "Regroup per concept dimension: #{TAG_DIMENSIONS.join(" | ")}." }
267
+ },
268
+ required: [ "bundle" ]
269
+ }
270
+ ) do |bundle:, by: nil|
271
+ folder = context.folder(bundle)
272
+ if by
273
+ groups = folder.tag_groups(by: by.to_sym)
274
+ distinct = groups.flat_map { |_, rows| rows.map { |row| row[:tag] } }.uniq.length
275
+ rows = groups.map { |key, tag_rows| { by.to_sym => key, count: tag_rows.length, tags: tag_rows } }
276
+ respond_json(with_unparseable(folder,
277
+ bundle: slug_of(context, bundle), total: distinct, by: by, groups: rows))
278
+ else
279
+ rows = inverted_rows(folder.graph(minimal: true).tag_index, :tag)
280
+ respond_json(with_unparseable(folder,
281
+ bundle: slug_of(context, bundle), total: rows.length, tags: rows))
282
+ end
283
+ end
284
+ end
285
+
286
+ def types_tool(context)
287
+ define_tool(
288
+ name: "types",
289
+ title: "Type index",
290
+ description: "The type index: every type with its count and concepts, ordered by count. " \
291
+ "§4.1's vocabulary is open — this is how you learn what a bundle's producer " \
292
+ "meant by its types before filtering the catalog on one.",
293
+ input_schema: {
294
+ properties: {
295
+ bundle: { type: "string", description: "A bundle slug from list_bundles." }
296
+ },
297
+ required: [ "bundle" ]
298
+ }
299
+ ) do |bundle:|
300
+ folder = context.folder(bundle)
301
+ rows = inverted_rows(folder.graph(minimal: true).type_index, :type)
302
+ respond_json(with_unparseable(folder,
303
+ bundle: slug_of(context, bundle), total: rows.length, types: rows))
304
+ end
305
+ end
306
+
307
+ # The sizing rollup — the kernel's Bundle#stats, whose by_dir keeps the
308
+ # zero a directory holding nothing directly honestly reports. The two
309
+ # dir keys deliberately speak two languages: by_dir is the disk,
310
+ # by_top_dir rolls up the id (the recorded identity-vs-physical split).
311
+ def stats_tool(context)
312
+ define_tool(
313
+ name: "stats",
314
+ title: "Bundle stats",
315
+ description: "Bundle rollups in one answer: concepts, dirs, types, cross-links, distinct " \
316
+ "tags, and the by_type/by_dir/by_top_dir distributions — \"how big is what I am " \
317
+ "about to read\". by_dir counts the file's directory (a dir holding nothing " \
318
+ "directly reports 0); by_top_dir rolls up the concept id's first segment.",
319
+ input_schema: {
320
+ properties: {
321
+ bundle: { type: "string", description: "A bundle slug from list_bundles." }
322
+ },
323
+ required: [ "bundle" ]
324
+ }
325
+ ) do |bundle:|
326
+ folder = context.folder(bundle)
327
+ rollup = folder.stats
328
+ respond_json(with_unparseable(folder,
329
+ bundle: slug_of(context, bundle),
330
+ concepts: rollup[:concepts], dirs: rollup[:dirs], top_dirs: rollup[:top_dirs],
331
+ concept_types: rollup[:types], cross_links: rollup[:cross_links],
332
+ distinct_tags: rollup[:tags],
333
+ by_type: rollup[:by_type], by_dir: rollup[:by_dir], by_top_dir: rollup[:by_top_dir]))
334
+ end
335
+ end
336
+
235
337
  # The consuming prompts, and only those — this gem's own text, written
236
338
  # against the tools above rather than the CLI. The skill's playbooks
237
339
  # were served here verbatim once, all eight, on the argument that a
@@ -279,6 +381,7 @@ module OKF
279
381
  def list_bundles_tool(context)
280
382
  define_tool(
281
383
  name: "list_bundles",
384
+ title: "List bundles",
282
385
  description: "What exists: every OKF bundle this server knows — slug, title, root, concept " \
283
386
  "count, type/tag rollups (top #{ROLLUP_LIMIT} each, with other_types/other_tags remainder " \
284
387
  "counts), which is the default, and whether its directory is missing — plus the " \
@@ -303,6 +406,7 @@ module OKF
303
406
  def dirs_tool(context)
304
407
  define_tool(
305
408
  name: "dirs",
409
+ title: "Directory tree",
306
410
  description: "The first move: a bundle's shape as one row per directory — `count` is the " \
307
411
  "concepts living directly there, `subtree` the weight at or below it, `subdirs` " \
308
412
  "its children. Orient here before anything else; it scales with the tree, not " \
@@ -330,6 +434,7 @@ module OKF
330
434
  def index_tool(context)
331
435
  define_tool(
332
436
  name: "index",
437
+ title: "Bundle index",
333
438
  description: "The bundle's index map, one directory at a time: the authored index.md body " \
334
439
  "(or `synthesized: true` where none exists), type/tag rollups, subdirectories, " \
335
440
  "and the concept listing an index there would enumerate — the view that surfaces " \
@@ -362,6 +467,7 @@ module OKF
362
467
  def search_tool(context)
363
468
  define_tool(
364
469
  name: "search",
470
+ title: "Search concepts",
365
471
  description: "Find concepts: every term must match (AND) across title, id, tags, type, " \
366
472
  "description, and body. Returns scored rows — each carrying its `bundle` and the " \
367
473
  "fields it `matched`, so results stay citable — with ids for read_concept. Omit " \
@@ -392,7 +498,15 @@ module OKF
392
498
  type: { type: "string", description: "Only concepts of this type." },
393
499
  dir: { type: "string", description: "Only concepts in this directory or below it (\".\" for the bundle root)." },
394
500
  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." }
501
+ status: { type: "string",
502
+ description: "Only concepts at this effective lifecycle status " \
503
+ "(absent reads stable — draft | stable | deprecated, or any declared value)." },
504
+ trust: { type: "string", description: "Only concepts at this trust tier (unverified | machine-confirmed | human-reviewed; either spelling)." },
505
+ limit: { type: "integer", minimum: 1, description: "Maximum rows to return (default #{SEARCH_LIMIT}); `total` is the count before the cut." },
506
+ fields: { type: "array", items: { type: "string" },
507
+ description: "Emit only these result-row keys (#{SEARCH_ROW_FIELDS.join(", ")})." },
508
+ except: { type: "array", items: { type: "string" },
509
+ description: "Emit every result-row key but these (mutually exclusive with fields)." }
396
510
  },
397
511
  required: [ "terms" ]
398
512
  }
@@ -404,6 +518,7 @@ module OKF
404
518
  def read_concept_tool(context)
405
519
  define_tool(
406
520
  name: "read_concept",
521
+ title: "Read a concept",
407
522
  description: "Read one concept's full markdown (frontmatter and body), live from disk — the " \
408
523
  "canonical copy. Ids are exact: take them from search, index, or catalog results.",
409
524
  input_schema: {
@@ -438,9 +553,11 @@ module OKF
438
553
  def catalog_tool(context)
439
554
  define_tool(
440
555
  name: "catalog",
556
+ title: "Catalog",
441
557
  description: "Per-concept metadata for a whole bundle — #{CATALOG_FIELDS.join(", ")} — " \
442
558
  "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 " \
559
+ "it), tag, status (effective: absent reads stable) and trust tier. Use it for " \
560
+ "inventories and rollups; use search when you " \
444
561
  "have terms. Returns `limit` rows (default #{CATALOG_LIMIT}) from `offset`; `total` is the " \
445
562
  "full count before slicing. `fields` projects each row down to the named keys.",
446
563
  input_schema: {
@@ -449,23 +566,27 @@ module OKF
449
566
  type: { type: "string", description: "Only concepts of this type." },
450
567
  dir: { type: "string", description: "Only concepts in this directory or below it (\".\" for the bundle root)." },
451
568
  tag: { type: "string", description: "Only concepts carrying this tag." },
452
- status: { type: "string", description: "Only concepts with this frontmatter status." },
569
+ status: { type: "string",
570
+ description: "Only concepts at this effective lifecycle status " \
571
+ "(absent reads stable — draft | stable | deprecated, or any declared value)." },
572
+ trust: { type: "string", description: "Only concepts at this trust tier (unverified | machine-confirmed | human-reviewed; either spelling)." },
453
573
  fields: { type: "array", items: { type: "string" }, description: "Emit only these row keys (#{CATALOG_FIELDS.join(", ")})." },
574
+ except: { type: "array", items: { type: "string" }, description: "Emit every row key but these (mutually exclusive with fields)." },
454
575
  limit: { type: "integer", minimum: 1, description: "Maximum concepts to return (default #{CATALOG_LIMIT})." },
455
576
  offset: { type: "integer", minimum: 0, description: "Skip this many concepts first (default 0)." }
456
577
  },
457
578
  required: [ "bundle" ]
458
579
  }
459
- ) do |bundle:, type: nil, dir: nil, tag: nil, status: nil, fields: nil, limit: CATALOG_LIMIT, offset: 0|
580
+ ) do |bundle:, type: nil, dir: nil, tag: nil, status: nil, trust: nil, fields: nil, except: nil, limit: CATALOG_LIMIT, offset: 0|
460
581
  root = context.root!(bundle)
461
- projection = check_fields(fields)
582
+ keep, drop = check_projection(fields, except)
462
583
  # One folder, held: the `dir` check and the unparseable count both
463
584
  # want it, and asking the residency layer twice walks the tree twice.
464
585
  folder = context.memory.folder(root)
465
586
  check_dir!(context, [ [ slug_of(context, bundle), root ] ], dir)
466
- rows = context.engine.catalog(root, { type: type, dir: dir, tag: tag, status: status })
587
+ rows = context.engine.catalog(root, { type: type, dir: dir, tag: tag, status: status, trust: trust })
467
588
  sliced = rows[offset, limit] || []
468
- sliced = sliced.map { |row| row.select { |key, _| projection.include?(key.to_s) } } if projection
589
+ sliced = project_rows(sliced, keep, drop)
469
590
  respond_json(with_unparseable(folder,
470
591
  bundle: slug_of(context, bundle), total: rows.length, concepts: sliced))
471
592
  end
@@ -474,6 +595,7 @@ module OKF
474
595
  def log_tool(context)
475
596
  define_tool(
476
597
  name: "log",
598
+ title: "Update log",
477
599
  description: "Read a bundle's append-only history — every log.md, root scope first, content " \
478
600
  "live from disk. Returns the newest #{LOG_LIMIT} date-grouped entries per file; " \
479
601
  "each file's `total` is how many it holds and `returned` how many came back, so " \
@@ -499,7 +621,8 @@ module OKF
499
621
  def validate_tool(context)
500
622
  define_tool(
501
623
  name: "validate",
502
- description: "The spec §9 conformance verdict: `conformant` (hard errors empty), every error " \
624
+ title: "Validate conformance",
625
+ description: "The spec §11 conformance verdict: `conformant` (hard errors empty), every error " \
503
626
  "and soft warning with its file and why — unopenable files included. Read-only: " \
504
627
  "you may flag what it finds; fixing it belongs to the okf skill and CLI. " \
505
628
  "Curation quality is lint's question, kept deliberately separate.",
@@ -524,11 +647,13 @@ module OKF
524
647
  def lint_tool(context)
525
648
  define_tool(
526
649
  name: "lint",
650
+ title: "Lint curation",
527
651
  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 " \
652
+ "provenance, attestation, migration, hygiene — as warnings and infos that never " \
653
+ "reject (conformance is validate's question). `only`/`except` select by check id " \
654
+ "(#{Bundle::Linter::CHECKS.join(", ")}). `stale_after` turns on the age cutoff: a " \
655
+ "duration like 90d or 12w, or an ISO date (declared expiries — `expired` — report " \
656
+ "out of the box). `group: \"folder\"` answers \"which " \
532
657
  "files float in the graph?\" instead — the unlinked concepts grouped by folder. " \
533
658
  "Noticing rot is yours; fixing it belongs to the okf skill and CLI.",
534
659
  input_schema: {
@@ -539,18 +664,22 @@ module OKF
539
664
  stale_after: { type: "string", description: "Flag concepts older than this: 90d, 12w, or an ISO date like 2026-01-01." },
540
665
  only: { type: "array", items: { type: "string" }, description: "Run only these checks (by check id)." },
541
666
  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." }
667
+ group: { type: "string", enum: LINT_GROUPS, description: "\"check\" (default) or \"folder\" — the unlinked files grouped by folder." },
668
+ today: { type: "string",
669
+ description: "The calendar day `expired` compares stale_after against, YYYY-MM-DD " \
670
+ "(default: today) — pin it for a reproducible, citable report." }
543
671
  },
544
672
  required: [ "bundle" ]
545
673
  }
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)
674
+ ) do |bundle:, min_body: nil, stale_after: nil, only: nil, except: nil, group: "check", today: nil|
675
+ lint_call(context, bundle, min_body, stale_after, only, except, group, today)
548
676
  end
549
677
  end
550
678
 
551
679
  def graph_tool(context)
552
680
  define_tool(
553
681
  name: "graph",
682
+ title: "Concept graph",
554
683
  description: "The knowledge graph, in three bounded views — never with concept bodies. " \
555
684
  "\"minimal\" (default): id/title nodes, edges, and the type/tag indexes — the " \
556
685
  "shape for planning a traversal. \"hubs\": concepts ranked by inbound links with " \
@@ -560,12 +689,41 @@ module OKF
560
689
  input_schema: {
561
690
  properties: {
562
691
  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)." }
692
+ view: { type: "string", enum: GRAPH_VIEWS, description: "One of #{GRAPH_VIEWS.join(", ")} (default minimal)." },
693
+ cut: { type: "integer", minimum: 1,
694
+ description: "traffic only: least arc weight to draw (default: fitted to the bundle)." }
695
+ },
696
+ required: [ "bundle" ]
697
+ }
698
+ ) do |bundle:, view: "minimal", cut: nil|
699
+ graph_call(context, bundle, view, cut)
700
+ end
701
+ end
702
+
703
+ # The kernel's §6.3 inventory as a tool: the one lens that sees the
704
+ # non-markdown files a bundle carries. Advisory like every read here —
705
+ # a dangling pointer is data for the caller, never a tool error.
706
+ def references_tool(context)
707
+ define_tool(
708
+ name: "references",
709
+ title: "References inventory",
710
+ description: "Inventory the bundle's references/ tree (§6.3): every file — the .py attesters " \
711
+ "and .sql computations no other tool can see included — with the concepts citing " \
712
+ "each through the §6.2 path-valued fields (resource, sources[].resource, " \
713
+ "computation, executor.resource, attester.resource), plus every pointer into " \
714
+ "references/ that resolves to nothing. A bare `references/…` written from a " \
715
+ "subdirectory is the classic miss, and the dangling entry names the " \
716
+ "leading-slash fix. Check it before trusting an attested computation's contract.",
717
+ input_schema: {
718
+ properties: {
719
+ bundle: { type: "string", description: "A bundle slug from list_bundles." }
564
720
  },
565
721
  required: [ "bundle" ]
566
722
  }
567
- ) do |bundle:, view: "minimal"|
568
- graph_call(context, bundle, view)
723
+ ) do |bundle:|
724
+ refs = context.folder(bundle).references
725
+ respond_json(bundle: slug_of(context, bundle), total: refs.entries.length,
726
+ references: refs.entries, dangling: refs.dangling)
569
727
  end
570
728
  end
571
729
 
@@ -588,6 +746,8 @@ module OKF
588
746
  check_dir!(context, pairs, options[:dir])
589
747
  rows = engine_rows(context, pairs, terms, fields, regexp, fuzzy, engine)
590
748
  rows = filter_rows(rows, options)
749
+ rows = narrow_by_catalog(rows, pairs, context, options)
750
+ keep, drop = check_projection(options[:fields], options[:except], allowed: SEARCH_ROW_FIELDS)
591
751
  limit = options[:limit] || SEARCH_LIMIT
592
752
 
593
753
  payload = {
@@ -599,12 +759,12 @@ module OKF
599
759
  # difference decides whether a miss means "absent" or "the
600
760
  # tokenizer shattered the identifier".
601
761
  engine: fuzzy || engine.to_s == "index" ? "index" : "scan",
602
- # §9 best-effort, surfaced per bundle: a corpus built from a folder
762
+ # §11 best-effort, surfaced per bundle: a corpus built from a folder
603
763
  # that skipped unusable files must say so, or a term living only in
604
764
  # an unreadable file reads back as "this bundle does not mention it".
605
765
  bundles: pairs.map { |slug, root| bundle_head_row(context, slug, root) },
606
766
  total: rows.length,
607
- results: rows.first(limit).map { |row| search_row(row, pairs) }
767
+ results: project_rows(rows.first(limit).map { |row| search_row(row, pairs) }, keep, drop)
608
768
  }
609
769
  payload[:skipped] = skipped unless skipped.empty?
610
770
  respond_json(payload)
@@ -690,13 +850,38 @@ module OKF
690
850
  # MCP client that fills every declared optional property with an empty
691
851
  # default (routine model behaviour) was handing `search` a filter that
692
852
  # matched nothing — while `catalog`, one tool over, read the same ""
693
- # as "no filter" and answered for the whole bundle.
853
+ # as "no filter" and answered for the whole bundle. The row rules
854
+ # themselves are the kernel's one predicate (Bundle::RowFilter).
694
855
  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]) })
856
+ wants = {}
857
+ %i[type dir tag].each do |key|
858
+ wanted = options[key]
859
+ next if OKF.blank?(wanted)
860
+
861
+ wants[key] = key == :dir ? Filters.normalize_dir(wanted) : wanted
699
862
  end
863
+ rows.select { |row| Bundle::RowFilter.matches?(row, **wants) }
864
+ end
865
+
866
+ # `status` and `trust` narrow through the catalog rather than through
867
+ # #filter_rows, because a search row carries what the engine matched on
868
+ # and the §5 families are not among it — handed to RowFilter they would
869
+ # read as absent and match nothing, which is worse than not offering
870
+ # the filter. So they resolve the way `okf search --status/--trust`
871
+ # resolves them: ask the catalog which ids qualify, per bundle, and
872
+ # keep the rows that survive. Same predicate underneath, so a `trust`
873
+ # that narrows `catalog` narrows `search` identically.
874
+ def narrow_by_catalog(rows, pairs, context, options)
875
+ wants = {}
876
+ %i[status trust].each do |key|
877
+ wants[key] = options[key] unless OKF.blank?(options[key])
878
+ end
879
+ return rows if wants.empty?
880
+
881
+ allowed = {}
882
+ pairs.each { |slug, root| allowed[slug] = context.engine.catalog(root, wants).to_set { |row| row[:id] } }
883
+ only = pairs.length == 1 ? pairs.first.first : nil
884
+ rows.select { |row| allowed[row[:slug] || only]&.include?(row[:id]) }
700
885
  end
701
886
 
702
887
  # The protocol row: `bundle` leads (identity first), the head already
@@ -715,7 +900,7 @@ module OKF
715
900
  }
716
901
  end
717
902
 
718
- def lint_call(context, bundle, min_body, stale_after, only, except, group)
903
+ def lint_call(context, bundle, min_body, stale_after, only, except, group, today = nil)
719
904
  folder = context.folder(bundle)
720
905
  if group == "folder"
721
906
  # The folder lens *is* the unlinked check, so the check-selection
@@ -723,7 +908,7 @@ module OKF
723
908
  # while silently dropping them let a caller read the full listing
724
909
  # as though its filter had been applied — and skipped the check-id
725
910
  # 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 }
911
+ offered = { only: only, except: except, min_body: min_body, stale_after: stale_after, today: today }
727
912
  given = offered.reject { |_, value| OKF.blank?(value) }.keys
728
913
  unless given.empty?
729
914
  raise Error, "group \"folder\" is the unlinked lens and takes no #{given.join("/")} — " \
@@ -744,6 +929,14 @@ module OKF
744
929
  opts = checks
745
930
  opts[:min_body] = min_body unless min_body.nil?
746
931
  opts[:stale_before] = parse_stale(stale_after) unless OKF.blank?(stale_after)
932
+ # The shell owns clock resolution (see parse_stale): the pure linter
933
+ # runs no `expired` check unless handed a day, the CLI passes
934
+ # Date.today, and this layer must too — without it, a bundle full of
935
+ # passed expiries linted healthy through MCP while the CLI reported
936
+ # every one. `today` pins the day instead — the CLI's --today, the
937
+ # reproducible-report lever — in the same deliberately narrow
938
+ # calendar-day grammar.
939
+ opts[:today] = parse_today(today)
747
940
  report = folder.lint(**opts)
748
941
  respond_json(
749
942
  bundle: slug_of(context, bundle),
@@ -773,7 +966,12 @@ module OKF
773
966
  with_unparseable(context.memory.folder(root), row)
774
967
  end
775
968
 
776
- def graph_call(context, bundle, view)
969
+ def graph_call(context, bundle, view, cut = nil)
970
+ # A cut outside traffic would be accepted and ignored — the
971
+ # half-honored argument is the silent-wrong-answer shape, so it
972
+ # refuses instead. The schema already floors it at 1.
973
+ raise Error, "cut applies to view: \"traffic\" only (got view: #{view.inspect})" if cut && view != "traffic"
974
+
777
975
  folder = context.folder(bundle)
778
976
  payload = with_unparseable(folder, bundle: slug_of(context, bundle), view: view)
779
977
  case view
@@ -785,7 +983,7 @@ module OKF
785
983
  hubs = folder.hubs
786
984
  payload.merge!(total: hubs.length, hubs: hubs)
787
985
  when "traffic"
788
- payload.merge!(traffic_payload(folder.skeleton))
986
+ payload.merge!(traffic_payload(folder.skeleton, cut))
789
987
  end
790
988
  respond_json(payload)
791
989
  end
@@ -794,8 +992,8 @@ module OKF
794
992
  # arcs above the fitted cut — the CLI's `graph --traffic`, as data. The
795
993
  # cohesion is measured over every arc; the cut only decides which arcs
796
994
  # are worth returning.
797
- def traffic_payload(skeleton)
798
- cut = skeleton.suggested_cut
995
+ def traffic_payload(skeleton, cut = nil)
996
+ cut ||= skeleton.suggested_cut
799
997
  out = Hash.new(0)
800
998
  into = Hash.new(0)
801
999
  skeleton.arcs.each do |arc|
@@ -816,9 +1014,10 @@ module OKF
816
1014
  # rescue turns domain failures into tool errors an agent can act on
817
1015
  # instead of opaque JSON-RPC internal errors — carrying the kernel's
818
1016
  # own sentences, never re-judged here.
819
- def define_tool(name:, description:, input_schema:, &body)
1017
+ def define_tool(name:, title:, description:, input_schema:, &body)
820
1018
  ::MCP::Tool.define(
821
1019
  name: name,
1020
+ title: title,
822
1021
  description: description,
823
1022
  # Looked up rather than passed, so a tool cannot ship without its
824
1023
  # shape being a deliberate omission — read_concept is the one
@@ -896,7 +1095,7 @@ module OKF
896
1095
  # ("# Runbooks Log") and costs a line.
897
1096
  #
898
1097
  # 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
1098
+ # is what content with no boundary is — §9 fixes no heading level, so
900
1099
  # `###` date groups are conformant and `LOG_ENTRY` cannot see them.
901
1100
  # But a *bare title* is not content: a scaffolded "# Update Log" with
902
1101
  # no entries yet holds zero, and counting it as one told an agent
@@ -949,7 +1148,7 @@ module OKF
949
1148
  row
950
1149
  end
951
1150
 
952
- # §9 best-effort, surfaced: a payload built from a folder that skipped
1151
+ # §11 best-effort, surfaced: a payload built from a folder that skipped
953
1152
  # unusable files says so, and validate names each file and why.
954
1153
  def with_unparseable(folder, payload)
955
1154
  count = folder.bundle.unparseable.length
@@ -1002,15 +1201,83 @@ module OKF
1002
1201
  asked
1003
1202
  end
1004
1203
 
1204
+ # The fields/except pair, checked as one ask: mutually exclusive, both
1205
+ # against the same vocabulary, so a typo'd except is refused by name
1206
+ # exactly the way a typo'd fields always was.
1207
+ def check_projection(fields, except, allowed: CATALOG_FIELDS)
1208
+ if !OKF.blank?(fields) && !OKF.blank?(except)
1209
+ raise Error, "fields and except are mutually exclusive — name what to keep or what to drop, not both"
1210
+ end
1211
+
1212
+ [ check_asked(fields, allowed), check_asked(except, allowed) ]
1213
+ end
1214
+
1215
+ def check_asked(asked, allowed)
1216
+ return nil if OKF.blank?(asked)
1217
+
1218
+ names = Array(asked).map { |field| field.to_s.downcase }
1219
+ unknown = names - allowed
1220
+ raise Error, "unknown field(s): #{unknown.join(", ")} (row keys: #{allowed.join(", ")})" unless unknown.empty?
1221
+
1222
+ names
1223
+ end
1224
+
1225
+ # keep (allowlist) or drop (denylist) per row; both nil passes through.
1226
+ def project_rows(rows, keep, drop)
1227
+ return rows if keep.nil? && drop.nil?
1228
+
1229
+ rows.map do |row|
1230
+ keep ? row.select { |key, _| keep.include?(key.to_s) } : row.reject { |key, _| drop.include?(key.to_s) }
1231
+ end
1232
+ end
1233
+
1234
+ # An inverted index ({ value => [id, …] }) as rows ordered by count
1235
+ # then value — the same order the CLI's index views print.
1236
+ def inverted_rows(index, key)
1237
+ index.map { |value, ids| { key => value, count: ids.length, concepts: ids } }
1238
+ .sort_by { |row| [ -row[:count], row[key] ] }
1239
+ end
1240
+
1241
+ # The calendar day `expired` compares against — wall clock unless the
1242
+ # caller pins one. Not stale_after's grammar: this names a day, not a
1243
+ # moment, and both refuse the basic/week ISO spellings Date.iso8601
1244
+ # would silently reinterpret (20260101, 2026-W01-1) — plus the
1245
+ # digit-shaped day that never existed (2026-02-30).
1246
+ def parse_today(value)
1247
+ return Date.today if OKF.blank?(value)
1248
+
1249
+ text = value.to_s
1250
+ raise Error, today_error(text) unless text.match?(OKF::Concept::ISO_DATE)
1251
+
1252
+ begin
1253
+ Date.iso8601(text)
1254
+ rescue ArgumentError
1255
+ raise Error, today_error(text)
1256
+ end
1257
+ end
1258
+
1259
+ def today_error(text)
1260
+ "today takes a calendar day, YYYY-MM-DD — got #{text.inspect}"
1261
+ end
1262
+
1005
1263
  # A stale_after ask (90d, 12w, or an ISO date) into the absolute Time
1006
1264
  # the pure Linter compares against — the CLI's resolution, kept here in
1007
1265
  # the shell so the linter never reads the clock.
1008
1266
  def parse_stale(value)
1009
- if (match = value.to_s.match(/\A(\d+)([dw])\z/))
1267
+ text = value.to_s
1268
+ if (match = text.match(/\A(\d+)([dw])\z/))
1010
1269
  days = match[1].to_i * (match[2] == "w" ? 7 : 1)
1011
1270
  Time.now - (days * 86_400)
1271
+ elsif text.match?(Concept::ISO_CUTOFF)
1272
+ # The kernel's cutoff grammar, which is also `okf lint
1273
+ # --stale-after`'s: a date or a full timestamp (what a caller gets
1274
+ # by reading a concept's `generated.at`), never the basic or week
1275
+ # spellings Date.iso8601 would silently reinterpret — this shell
1276
+ # would otherwise answer about one bundle's staleness from a value
1277
+ # its own CLI exits 2 on.
1278
+ Date.iso8601(text).to_time
1012
1279
  else
1013
- Date.iso8601(value.to_s).to_time
1280
+ raise Error, "invalid stale_after #{value.inspect} — use 90d, 12w, or an ISO date like 2026-01-01"
1014
1281
  end
1015
1282
  rescue ArgumentError
1016
1283
  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.2.0"
6
6
  end
7
7
  end
data/lib/okf/mcp.rb CHANGED
@@ -16,5 +16,13 @@ module OKF
16
16
  # so an embedding app never pays for the protocol machinery.
17
17
  module MCP
18
18
  class Error < StandardError; end
19
+
20
+ # The Rack seam — the same server definition and stateless transport
21
+ # `--http` serves, as the app a config.ru runs (`run OKF::MCP.app`).
22
+ # Lazy, so requiring this file never pays for the protocol machinery.
23
+ def self.app(refs = [], **options)
24
+ require_relative "mcp/app"
25
+ App.build(refs, **options)
26
+ end
19
27
  end
20
28
  end