Module: OKF::MCP::Server
- Defined in:
- lib/okf/mcp/server.rb
Overview
Builds the MCP::Server definition shared by every transport: ten read-only tools mapped straight onto the kernel's library API, plus the two consuming prompts. The surface is deliberately minimal — agents do better with few, well-described tools, and dirs + index + search compose to most retrieval — so adding a tool here is a design decision, not a convenience.
Every list output is bounded with a visible total; no silent
truncation, ever. total means one thing on every tool: how many rows
the request matched, before any limit cut them. dirs and index used to
report the whole bundle's directory count instead, so a narrowing stayed
visible — defensible alone, wrong as a set, and against the promise above
it read as rows withheld by a tool that takes no limit at all.
The descriptions carry the skill's doctrine, because they are the only playbook a Desktop host ever sees.
Defined Under Namespace
Classes: Context, Definition
Constant Summary collapse
- ROLLUP_LIMIT =
Cap on the distinct type/tag values list_bundles rolls up per bundle; the remainder is reported as an
other_types/other_tagscount so a huge bundle cannot flood a context window with a long-tail histogram. 25- SEARCH_LIMIT =
20- TAG_DIMENSIONS =
The two concept dimensions
tagsregroups by (§4.1'stype, the file's directory) — the same pair the CLI's--byoffers, minus its deprecatedareaspelling, which a new surface does not inherit. %w[type dir].freeze
- SEARCH_ROW_FIELDS =
The keys a search result row carries — the vocabulary
fields/exceptproject against, declared so a typo is refused by name even when a query happens to match nothing. %w[bundle id title type tags matched score snippet].freeze
- CATALOG_LIMIT =
200- LOG_LIMIT =
Date-grouped entries returned per log.md. A log is the one file in a bundle that only ever grows and never gets curated, so its size tracks the project's age rather than the question asked — this repo's own answered "what changed recently" with 119,863 bytes across fourteen dates, and
totalcounting files made that read as bounded. Three is what "recently" means when the entries are date-grouped;limitasks for more. 3- LOG_ENTRY =
A §9 log is "a flat list of date-grouped entries, newest first", so a
##heading at column 0 is the entry boundary. Split kept here rather than pushed into the kernel because bounding for a context window is this surface's problem alone: the graph page's Log panel wants the whole file and scrolls it. If a second consumer ever needs the entries — anokf logverb is the obvious one — the split moves to the kernel and both read it from there. /^(?=## )/.freeze
- LOG_BUDGET =
Bytes per
limitunit — the size cap every log answer is cut to, announced withtruncated: true(see #sized_log). Sized off the measured shape rather than guessed: this repo's own three-entry answer is 13,491 bytes, so one entry is budgeted at roughly what one date group costs. The cap exists because counting entries alone bounds nothing when one entry carries the whole file — a log under a single##heading, or under headings the split cannot see. 4_500- CATALOG_FIELDS =
The catalog row, the projection vocabulary
fieldsselects from. %w[id title type description tags generated_at generated_by generated trust status stale_after sources backlog_ref dir top_dir links_out links_in].freeze
- GRAPH_VIEWS =
%w[minimal hubs traffic].freeze
- LINT_GROUPS =
%w[check folder].freeze
- CAPABILITIES =
Declared explicitly, because the SDK's default announces every capability it knows how to route — and this server used to inherit a
resourcesdeclaration whileresources/listanswered[], plus aloggingone nothing ever emits through. That is the same class of dishonesty thereadOnlyHintannotations are careful to avoid, so the hash below names exactly what is served and nothing else.No
listChangedon any of them and nosubscribe: nothing here sends a notification, and claiming otherwise would invite a host to wait for one. The tool and prompt lists are genuinely fixed for a process's life; the resource list is not — it tracks the registry (see Definition), so a host that re-lists sees the current set, and one that caches the boot listing is stale until it asks again. DeclaringlistChangedis what would fix that, and it stays undeclared until something actually notifies. { tools: {}, prompts: {}, resources: {}, completions: {} }.freeze
- INSTRUCTIONS =
<<~TEXT Tools take a `bundle` argument: a slug from list_bundles — the same name `@slug` resolves at the okf CLI. The retrieval discipline: orient with dirs (the shape), descend with index (a directory's map), answer pointed questions with search (omit `bundle` to search every bundle), and read only the winning concepts with read_concept — never slurp a bundle whole. Check log for recent history. validate and lint report a bundle's health: you may flag what they find, but fixing it belongs to the okf skill and CLI — the tools here never write. Bodies are read live from disk, so results always reflect the current files. TEXT
- PROMPTS =
The consuming prompts, and only those — this gem's own text, written against the tools above rather than the CLI. The skill's playbooks were served here verbatim once, all eight, on the argument that a prompt is instructions rather than a capability; what that argument missed is whose instructions they were. Every playbook speaks in
okf …invocations and half dead-end a CLI-less host at "install the CLI first", so to the host this surface exists for they taught a vocabulary it cannot use and a mission (authoring) its tools refuse. This server makes a client an expert consumer of bundles; producing, migrating, maintaining, refining and curating stay with the skill, which is installed where a filesystem and the CLI actually are.Owning the text is the cost: these two restate the skill's retrieval doctrine in tool vocabulary, so a doctrine change there must be carried here by hand. The pair keeps SKILL.md's Commands-table order (search before consume).
{ "okf-search" => [ "search", "find the concepts that answer a pointed question, without paying for the whole bundle" ], "okf-consume" => [ "consume", "answer questions from an OKF bundle without reading it whole" ] }.freeze
Class Method Summary collapse
-
.build(registry, engine: Backend.detect, configuration: nil) ⇒ Object
configuration:is the seam the output-schema suite needs: it turns on the SDK's result validation so a schema that has drifted from its payload fails a test. -
.prompt_text(name) ⇒ Object
This gem's own prompts/ tree, read at get-time so booting never pays for it.
- .prompts ⇒ Object
-
.stats_tool(context) ⇒ Object
The sizing rollup — the kernel's Bundle#stats, whose by_dir keeps the zero a directory holding nothing directly honestly reports.
-
.tags_tool(context) ⇒ Object
tags' plain view is the inverted index;
byis the curation view — the kernel's Bundle#tag_groups, so a within-group count beside its cross-bundle total reads identically here and on the CLI. - .tools_for(context) ⇒ Object
- .types_tool(context) ⇒ Object
Class Method Details
.build(registry, engine: Backend.detect, configuration: nil) ⇒ Object
configuration: is the seam the output-schema suite needs: it turns
on the SDK's result validation so a schema that has drifted from its
payload fails a test. Left nil in production deliberately — a schema
bug should not turn a working tool into a runtime error.
206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 |
# File 'lib/okf/mcp/server.rb', line 206 def build(registry, engine: Backend.detect, configuration: nil) memory = engine.is_a?(MemoryBackend) ? engine : MemoryBackend.new context = Context.new(registry, engine, memory) server = Definition.new( registry: registry, context: context, name: "okf", title: "OKF knowledge bundles", version: VERSION, instructions: INSTRUCTIONS, capabilities: CAPABILITIES, configuration: configuration, tools: tools_for(context), prompts: prompts, resources: [], resource_templates: Resources.templates ) # Replaces the SDK's URI matching wholesale, because an OKF id below # the root carries slashes and the template matcher binds a variable # to `[^/]+`. Resources owns the parsing; the template is signage. server.resources_read_handler { |params| Resources.read(context, params[:uri]) } server.completion_handler { |params| { completion: { values: Resources.complete(context, params) } } } server end |
.prompt_text(name) ⇒ Object
This gem's own prompts/ tree, read at get-time so booting never pays for it. No missing-file rescue like the tools carry: the old path read from the installed kernel's skill tree, where version skew could orphan a name, but a file shipped in this gem beside this file can only be absent when the package itself is broken.
372 373 374 |
# File 'lib/okf/mcp/server.rb', line 372 def prompt_text(name) ::File.read(::File.join(__dir__, "prompts", "#{name}.md"), encoding: "UTF-8") end |
.prompts ⇒ Object
355 356 357 358 359 360 361 362 363 364 365 |
# File 'lib/okf/mcp/server.rb', line 355 def prompts PROMPTS.map do |name, (file, description)| # `**` absorbs the server_context: the SDK's template passes. ::MCP::Prompt.define(name: name, description: description) do |_args, **| ::MCP::Prompt::Result.new( description: description, messages: [ ::MCP::Prompt::Message.new(role: "user", content: ::MCP::Content::Text.new(Server.prompt_text(file))) ] ) end end end |
.stats_tool(context) ⇒ Object
The sizing rollup — the kernel's Bundle#stats, whose by_dir keeps the zero a directory holding nothing directly honestly reports. The two dir keys deliberately speak two languages: by_dir is the disk, by_top_dir rolls up the id (the recorded identity-vs-physical split).
309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 |
# File 'lib/okf/mcp/server.rb', line 309 def stats_tool(context) define_tool( name: "stats", description: "Bundle rollups in one answer: concepts, dirs, types, cross-links, distinct " \ "tags, and the by_type/by_dir/by_top_dir distributions — \"how big is what I am " \ "about to read\". by_dir counts the file's directory (a dir holding nothing " \ "directly reports 0); by_top_dir rolls up the concept id's first segment.", input_schema: { properties: { bundle: { type: "string", description: "A bundle slug from list_bundles." } }, required: [ "bundle" ] } ) do |bundle:| folder = context.folder(bundle) rollup = folder.stats respond_json(with_unparseable(folder, bundle: slug_of(context, bundle), concepts: rollup[:concepts], dirs: rollup[:dirs], top_dirs: rollup[:top_dirs], concept_types: rollup[:types], cross_links: rollup[:cross_links], distinct_tags: rollup[:tags], by_type: rollup[:by_type], by_dir: rollup[:by_dir], by_top_dir: rollup[:by_top_dir])) end end |
.tags_tool(context) ⇒ Object
tags' plain view is the inverted index; by is the curation view —
the kernel's Bundle#tag_groups, so a within-group count beside its
cross-bundle total reads identically here and on the CLI.
253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 |
# File 'lib/okf/mcp/server.rb', line 253 def (context) define_tool( name: "tags", description: "The tag index: every tag with its count and concepts, ordered by count. " \ "`by: \"dir\"` or `by: \"type\"` regroups per concept dimension for vocabulary " \ "curation — each tag then carries `count` (within the group) beside `total` " \ "(across the bundle), so a tag local to one group and one scattered across " \ "several read differently at a glance.", input_schema: { properties: { bundle: { type: "string", description: "A bundle slug from list_bundles." }, by: { type: "string", enum: TAG_DIMENSIONS, description: "Regroup per concept dimension: #{TAG_DIMENSIONS.join(" | ")}." } }, required: [ "bundle" ] } ) do |bundle:, by: nil| folder = context.folder(bundle) if by groups = folder.tag_groups(by: by.to_sym) distinct = groups.flat_map { |_, rows| rows.map { |row| row[:tag] } }.uniq.length rows = groups.map { |key, tag_rows| { by.to_sym => key, count: tag_rows.length, tags: tag_rows } } respond_json(with_unparseable(folder, bundle: slug_of(context, bundle), total: distinct, by: by, groups: rows)) else rows = inverted_rows(folder.graph(minimal: true).tag_index, :tag) respond_json(with_unparseable(folder, bundle: slug_of(context, bundle), total: rows.length, tags: rows)) end end end |
.tools_for(context) ⇒ Object
231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 |
# File 'lib/okf/mcp/server.rb', line 231 def tools_for(context) [ list_bundles_tool(context), dirs_tool(context), index_tool(context), search_tool(context), read_concept_tool(context), catalog_tool(context), log_tool(context), validate_tool(context), lint_tool(context), graph_tool(context), references_tool(context), (context), types_tool(context), stats_tool(context) ] end |
.types_tool(context) ⇒ Object
285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 |
# File 'lib/okf/mcp/server.rb', line 285 def types_tool(context) define_tool( name: "types", description: "The type index: every type with its count and concepts, ordered by count. " \ "§4.1's vocabulary is open — this is how you learn what a bundle's producer " \ "meant by its types before filtering the catalog on one.", input_schema: { properties: { bundle: { type: "string", description: "A bundle slug from list_bundles." } }, required: [ "bundle" ] } ) do |bundle:| folder = context.folder(bundle) rows = inverted_rows(folder.graph(minimal: true).type_index, :type) respond_json(with_unparseable(folder, bundle: slug_of(context, bundle), total: rows.length, types: rows)) end end |