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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +133 -0
- data/README.md +38 -7
- data/lib/okf/mcp/app.rb +91 -0
- data/lib/okf/mcp/cli.rb +1 -1
- data/lib/okf/mcp/filters.rb +9 -11
- data/lib/okf/mcp/http.rb +186 -20
- data/lib/okf/mcp/memory_backend.rb +12 -8
- data/lib/okf/mcp/output_schemas.rb +37 -1
- data/lib/okf/mcp/prompts/search.md +2 -2
- data/lib/okf/mcp/server.rb +306 -39
- data/lib/okf/mcp/version.rb +1 -1
- data/lib/okf/mcp.rb +8 -0
- metadata +12 -5
data/lib/okf/mcp/server.rb
CHANGED
|
@@ -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 §
|
|
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
|
|
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
|
-
|
|
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,
|
|
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",
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
529
|
-
"validate's question). `only`/`except` select by check id " \
|
|
530
|
-
"(#{Bundle::Linter::CHECKS.join(", ")}). `stale_after` turns on
|
|
531
|
-
"duration like 90d or 12w, or an ISO date
|
|
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
|
|
568
|
-
|
|
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
|
-
# §
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
|
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 — §
|
|
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
|
-
# §
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
data/lib/okf/mcp/version.rb
CHANGED
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
|