okf-mcp 1.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f19f69227417cc91ee456e6fca9f517ae1d8f4d571bb7423c7d76d4e38df238
4
- data.tar.gz: dcd1b33fb97cb5eb47bbf01681b2b291d26b035c33d9dc5585422edd3f00e165
3
+ metadata.gz: a730c3c4e2bc94cfdc83704f96ae40425cce8e561e8bb77f84abd153607c81d3
4
+ data.tar.gz: a1651babe0d4dd972d0d83590aafddba43f411a7f70f2047b92933b8f4dbc281
5
5
  SHA512:
6
- metadata.gz: 1821ef4c6684ec3ca30d3368f1c4ee07c308f24693278b38866e91f2ad1b12b8f742d45cc42c02d002c5f75bd9cd007e64aec155de439c1f2a47d9c12bedc0f8
7
- data.tar.gz: 399fdd6c71fad193ce28256b5982d6de87fe547ac818aa4813a72981f8afe8733488f91a420c0232dc58221bcd29dd31f2a8fa318ffc584af521fd8d1d9d0279
6
+ metadata.gz: d979addc867e9ee49f0fe3a5090915c63b3b9da55d92e89863e461022ce1316e110e80f5b4ceee6f4076a5171be3fad3691aa10fd732144b6e0dcf5ec0711c25
7
+ data.tar.gz: 4b52f99b0b429c500acd8fc2af91614f8a0dad69f6d8877533a701aa66421cac9cf1351e35b7b4eaa463e06a8af86506cb8af366cf21e4d6ebc2a74dea3ee8ce
data/CHANGELOG.md CHANGED
@@ -5,6 +5,59 @@ 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
+ ## [Unreleased]
9
+
10
+ ## [1.2.0] - 2026-08-18
11
+
12
+ ### Added
13
+
14
+ - **`OKF::MCP.app` — the Rack seam.** The same server definition and
15
+ stateless transport `okf mcp --http` serves, as the app a `config.ru`
16
+ runs: `run OKF::MCP.app` serves the registered bundles under puma,
17
+ unicorn, or any Rack 3 server, with argv-shaped refs and
18
+ `allowed_hosts:`/`allowed_origins:` for a reverse proxy. The server you
19
+ mount it under is your dependency, not this gem's — the no-rackup
20
+ position holds, and `--http` on the kernel's WEBrick stays the
21
+ zero-config default. The entry is lazy and a loading test pins it:
22
+ `require "okf/mcp"` still loads neither the SDK nor WEBrick.
23
+
24
+ - **Every tool carries a `title`** — the human name a host displays where it
25
+ lists tools ("Search concepts", "Validate conformance"), beside the wire
26
+ name an agent calls. All fourteen or none, and a capabilities test pins
27
+ all fourteen: a mix of titled and untitled tools reads as a half-finished
28
+ server in a Desktop listing.
29
+
30
+ ### Fixed
31
+
32
+ - **`subscriptions/listen` streams through the WEBrick bridge** instead of
33
+ crashing it. The SDK answers the modern lifecycle's listen with a Rack
34
+ streaming body — a callable, not an enumerable — and the bridge's buffering
35
+ loop raised `NoMethodError`, handing the client WEBrick's HTML 500 page
36
+ with the exception text in it: a broken server where the spec's
37
+ notification stream should open. The bridge now serves callable bodies over
38
+ a chunked response, parking the handler thread until the SDK ends the
39
+ stream (a dead peer's `EPIPE`, or the transport's close at shutdown — which
40
+ now runs before WEBrick's, whose thread-join would otherwise hang on an
41
+ open stream). Concurrent listens are capped at 32 on this bridge — each one
42
+ holds a WEBrick thread and connection token, unlike under a Rack 3 server —
43
+ and since this server declares no `listChanged` or `subscribe` capability,
44
+ the acknowledged filter is always empty: a stream carries keepalives, never
45
+ a notification, and that is the conformant answer.
46
+
47
+ ### Changed
48
+
49
+ - **The mcp floor moves to `~> 1.2`** — the SDK this shell rides released
50
+ 1.2.0, completing the SEP-2575 stateless modern lifecycle of the
51
+ 2026-07-28 protocol, and the suite now proves wire behavior (the modern
52
+ envelope, `server/discover`, the streamed `subscriptions/listen`) that
53
+ 1.0 and 1.1 never served. The floor tracks what the suite proves, and a
54
+ new unit guard pins it the same way the okf floor is pinned: a lockfile
55
+ that resolves past the floor fails the suite until the gemspec follows.
56
+ - **The okf floor moves to `>= 2.1, < 3`** — the kernel this gem develops
57
+ against released 2.1.0, and the floor tracks what the suite proves against
58
+ (the gemspec drill enforces equality as the normal state). Nothing here calls
59
+ a 2.1-only surface; the ceiling is unchanged.
60
+
8
61
  ## [1.1.0] - 2026-08-14
9
62
 
10
63
  ### Added
@@ -291,6 +344,7 @@ rather than pretending to be changes somebody could have seen.
291
344
 
292
345
  The name reservation on RubyGems: an empty gem, no functionality.
293
346
 
347
+ [1.2.0]: https://github.com/serradura/okf-gem/compare/okf-mcp/v1.1.0...okf-mcp/v1.2.0
294
348
  [1.1.0]: https://github.com/serradura/okf-gem/compare/okf-mcp/v1.0.0...okf-mcp/v1.1.0
295
349
  [1.0.0]: https://github.com/serradura/okf-gem/releases/tag/okf-mcp/v1.0.0
296
350
  [0.0.0]: https://rubygems.org/gems/okf-mcp/versions/0.0.0
data/README.md CHANGED
@@ -51,6 +51,26 @@ not access control, and it cannot be — a client that is not a browser sets
51
51
  `Host` to whatever it likes. Treat a non-loopback bind the way you would treat
52
52
  serving your notes directory over HTTP, because that is what it is.
53
53
 
54
+ `--http` runs on the WEBrick the okf kernel already ships — zero configuration,
55
+ no extra dependency. To host the same server under puma, unicorn, or any other
56
+ Rack server instead, `OKF::MCP.app` is the same definition and stateless
57
+ transport as a Rack app, ready for a `config.ru`:
58
+
59
+ ```ruby
60
+ # config.ru — run with: bundle exec puma
61
+ require "okf/mcp"
62
+
63
+ run OKF::MCP.app # no args: the registered bundles
64
+ # run OKF::MCP.app([ "@handbook", "./docs" ]) # or exactly these
65
+ # run OKF::MCP.app(allowed_hosts: [ "mcp.example.com" ]) # behind a proxy or DNS name
66
+ ```
67
+
68
+ The server you run it under is your dependency, not this gem's, and it needs
69
+ the Rack 3 SPEC (the `subscriptions/listen` notification stream is a Rack 3
70
+ streaming body). Everything above about non-loopback binds applies verbatim:
71
+ a Rack server reachable beyond loopback publishes every served bundle, with
72
+ no authentication.
73
+
54
74
  Whatever argv names is the whole served set. A registry ref is resolved once, at
55
75
  boot; no tool argument can widen the set afterwards, so a group slug reaches
56
76
  bundles only when the registry itself is what is being served.
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "server"
6
+
7
+ module OKF
8
+ module MCP
9
+ # The Rack seam: the same server definition and stateless transport the
10
+ # `--http` verb serves, as the app a config.ru runs — so puma, unicorn or
11
+ # any Rack server can host the bundles without this gem depending on one
12
+ # (the reader's server is the reader's dependency; the no-rackup position
13
+ # holds). This module owns transport construction; the WEBrick bridge
14
+ # (okf/mcp/http.rb) builds through it, so the options exist in one place.
15
+ #
16
+ # Deliberately no listen cap here, unlike the WEBrick bridge's 32: under
17
+ # a Rack 3 server a `subscriptions/listen` stream holds no thread — the
18
+ # SDK's callable body returns immediately — so the SDK's own default is
19
+ # the right bound.
20
+ module App
21
+ # The largest request body the transport reads, matching the SDK's own
22
+ # StreamableHTTPTransport default.
23
+ MAX_REQUEST_BYTES = 4 * 1024 * 1024
24
+
25
+ # The MCP endpoint is the root and nothing else. The SDK transport
26
+ # routes on method alone, so handed every path it answers a connecting
27
+ # host's OAuth discovery probes (GET /.well-known/*, POST /register —
28
+ # Claude Desktop sends both) with a 405 or a 200-wrapped JSON-RPC parse
29
+ # error: a *broken* sign-in service instead of an absent one, and the
30
+ # host refuses the connector on it. The WEBrick bridge scopes in its
31
+ # own #handle; this wrapper is the same refusal for a config.ru host.
32
+ class Scope
33
+ def initialize(transport)
34
+ @transport = transport
35
+ end
36
+
37
+ def call(env)
38
+ path = env["PATH_INFO"].to_s
39
+ # "" is the mounted spelling: `map "/mcp"` hands the mount point
40
+ # itself an empty PATH_INFO.
41
+ return not_found unless path.empty? || path == "/"
42
+
43
+ @transport.call(env)
44
+ end
45
+
46
+ def close
47
+ @transport.close
48
+ end
49
+
50
+ private
51
+
52
+ def not_found
53
+ [ 404, { "content-type" => "application/json" },
54
+ [ JSON.generate(error: "not found: the MCP endpoint is /") ] ]
55
+ end
56
+ end
57
+
58
+ module_function
59
+
60
+ # +refs+ are argv-shaped bundle names — directories and @slugs, exactly
61
+ # what `okf mcp` takes; empty serves the kernel registry, exactly like
62
+ # `okf mcp` with no args. +allowed_hosts+/+allowed_origins+ widen the
63
+ # SDK's DNS-rebinding allowlists for a reverse proxy or a DNS name; the
64
+ # same posture as `--allow-host`, and the same warning — the allowlist
65
+ # is not access control, and a Rack server bound beyond loopback
66
+ # publishes every served bundle with no authentication.
67
+ def build(refs = [], engine: nil, allowed_hosts: nil, allowed_origins: nil)
68
+ registry = Array(refs).empty? ? Registry.from_kernel : Registry.from_argv(Array(refs))
69
+ server = Server.build(registry, engine: engine || Backend.detect)
70
+ Scope.new(transport(server, allowed_hosts: allowed_hosts, allowed_origins: allowed_origins))
71
+ end
72
+
73
+ # Wires one transport to one server definition. Every construction site
74
+ # — this seam and the WEBrick bridge — goes through here, so the shared
75
+ # posture (stateless, JSON responses, the body cap, the blank-allowlist
76
+ # guard) is stated once and cannot drift between the two; a caller
77
+ # passes only what is its own (the bridge: its listen cap).
78
+ def transport(server, allowed_hosts: nil, allowed_origins: nil, **options)
79
+ options = { stateless: true, enable_json_response: true,
80
+ max_request_bytes: MAX_REQUEST_BYTES }.merge(options)
81
+ hosts = Array(allowed_hosts)
82
+ options[:allowed_hosts] = hosts unless hosts.empty?
83
+ origins = Array(allowed_origins)
84
+ options[:allowed_origins] = origins unless origins.empty?
85
+ app = ::MCP::Server::Transports::StreamableHTTPTransport.new(server, **options)
86
+ server.transport = app
87
+ app
88
+ end
89
+ end
90
+ end
91
+ end
data/lib/okf/mcp/http.rb CHANGED
@@ -4,7 +4,7 @@ require "socket"
4
4
  require "stringio"
5
5
  require "webrick"
6
6
 
7
- require_relative "server"
7
+ require_relative "app"
8
8
 
9
9
  module OKF
10
10
  module MCP
@@ -25,10 +25,111 @@ module OKF
25
25
  # Binds that mean "every interface" rather than one address.
26
26
  WILDCARD_BINDS = %w[0.0.0.0 :: *].freeze
27
27
 
28
- # The largest request body the bridge will hand the transport, matching
29
- # the SDK's own StreamableHTTPTransport default. Anything past it is 413
30
- # before it is allocated (see #read_body).
31
- MAX_REQUEST_BYTES = 4 * 1024 * 1024
28
+ # The largest request body the bridge will hand the transport — the
29
+ # Rack seam's constant, aliased because #read_body enforces it here too:
30
+ # anything past it is 413 before it is allocated.
31
+ MAX_REQUEST_BYTES = App::MAX_REQUEST_BYTES
32
+
33
+ # Cap on concurrent `subscriptions/listen` streams, far below the SDK's
34
+ # 1000 default because the costs differ in kind: under a Rack 3 server a
35
+ # stream holds no thread, but on this bridge each one parks a WEBrick
36
+ # handler thread *and* occupies one of WEBrick's 100 connection tokens —
37
+ # at the SDK default the tokens exhaust at 100 and every tool call
38
+ # queues behind held streams. 32 leaves two-thirds of the tokens for
39
+ # request traffic. A constant, not a flag: zero-config is this mode's
40
+ # posture, and an operator who needs more has the Rack seam.
41
+ MAX_LISTEN_STREAMS = 32
42
+
43
+ # What the SDK writes SSE frames to, adapting its stream contract to
44
+ # WEBrick's proc-body one. The SDK expects write/flush per frame, EPIPE
45
+ # out of write to mean the peer is gone, and close to end the stream;
46
+ # WEBrick ends the response when the body proc returns. So the proc
47
+ # parks in #wait until the SDK — its keepalive thread on a dead peer, or
48
+ # the transport's own close — calls #close, and only then hands the
49
+ # thread back (see #stream_response).
50
+ class Stream
51
+ def initialize(wire)
52
+ @wire = wire
53
+ @state = Mutex.new
54
+ @wire_lock = Mutex.new
55
+ @done = ConditionVariable.new
56
+ @closed = false
57
+ end
58
+
59
+ # One frame, one chunk. A dead peer raises EPIPE/ECONNRESET straight
60
+ # out of the socket write — exactly the signal the SDK's stream
61
+ # cleanup keys on, so it must never be swallowed here.
62
+ #
63
+ # The wire has its own lock, deliberately separate from the state's:
64
+ # a *stalled* peer (alive, not reading, send buffer full) parks the
65
+ # write with no EPIPE to raise, and #close belongs to shutdown — it
66
+ # must never queue behind a peer's buffer, so it takes only the state
67
+ # lock. A write racing close lands on a socket that is closing
68
+ # anyway; the resulting IOError/EPIPE is the SDK's cleanup signal.
69
+ def write(data)
70
+ @state.synchronize do
71
+ raise IOError, "stream is closed" if @closed
72
+ end
73
+ @wire_lock.synchronize { @wire.write(data) }
74
+ end
75
+
76
+ # WEBrick's ChunkedWrapper has no flush; each write already reaches
77
+ # the socket as a complete chunk.
78
+ def flush
79
+ @wire.flush if @wire.respond_to?(:flush)
80
+ nil
81
+ end
82
+
83
+ def close
84
+ @state.synchronize do
85
+ @closed = true
86
+ @done.broadcast
87
+ end
88
+ end
89
+
90
+ def wait
91
+ @state.synchronize { @done.wait(@state) until @closed }
92
+ end
93
+ end
94
+
95
+ # The bridge's own ledger of parked streams, one per WEBrick server.
96
+ # The SDK closes the streams it knows about, but a listen that races
97
+ # the transport's close registers *after* that sweep and would park
98
+ # forever — and a supervisor sends exactly one signal, so "the second
99
+ # TERM re-sweeps" is termination lost. The latch closes the race: once
100
+ # #close_all has run, an admitted stream is closed on the spot and its
101
+ # handler thread parks for no time at all.
102
+ class Streams
103
+ def initialize
104
+ @lock = Mutex.new
105
+ @streams = []
106
+ @closed = false
107
+ end
108
+
109
+ def admit(stream)
110
+ late = @lock.synchronize do
111
+ if @closed
112
+ true
113
+ else
114
+ @streams << stream
115
+ false
116
+ end
117
+ end
118
+ stream.close if late
119
+ end
120
+
121
+ def discard(stream)
122
+ @lock.synchronize { @streams.delete(stream) }
123
+ end
124
+
125
+ def close_all
126
+ parked = @lock.synchronize do
127
+ @closed = true
128
+ @streams.dup
129
+ end
130
+ parked.each(&:close)
131
+ end
132
+ end
32
133
 
33
134
  # Everything before accepting — the bind (where EADDRINUSE, the boot
34
135
  # failure that actually happens, raises), the traps and the boot line —
@@ -38,21 +139,40 @@ module OKF
38
139
  def prepare(server, bind:, port:, allow_hosts: [], out: $stderr)
39
140
  app = app_for(server, bind: bind, allowed_hosts: allowed_hosts_for(bind, extra: allow_hosts))
40
141
  httpd = build(app, bind: bind, port: port)
41
- %w[INT TERM].each { |signal| trap(signal) { httpd.shutdown } }
142
+ # The trap spawns a thread because #stop takes the transport's mutex,
143
+ # and a mutex inside trap context is ThreadError on Ruby 2.7.
144
+ %w[INT TERM].each { |signal| trap(signal) { Thread.new { stop(httpd, app) } } }
42
145
  announce(httpd, bind: bind, out: out)
43
146
  httpd
44
147
  end
45
148
 
149
+ # Teardown in the only order that terminates: the transport first, so
150
+ # every open listen stream is closed and its parked handler thread
151
+ # returns (see #stream_response) — WEBrick's own shutdown *joins* the
152
+ # connection threads, so closing it first would hang on any open stream.
153
+ #
154
+ # The ensure is the signal's guarantee: a supervisor sends exactly one
155
+ # TERM, so whatever the transport's close raises, the stream latch still
156
+ # trips (a listen racing the close is closed on admission, never parked)
157
+ # and WEBrick still comes down. One signal, one dead server, always.
158
+ def stop(httpd, app)
159
+ app.close
160
+ ensure
161
+ httpd.config[:okf_mcp_streams]&.close_all
162
+ httpd.shutdown
163
+ end
164
+
46
165
  # The SDK transport wired to the server, in stateless JSON mode. A
47
166
  # non-loopback bind (e.g. 0.0.0.0) is refused by the SDK's DNS-rebinding
48
167
  # guard unless its Host is allowlisted; loopback binds keep the SDK
49
168
  # defaults. Protection itself stays on either way.
50
- def app_for(server, bind:, allowed_hosts: allowed_hosts_for(bind))
51
- options = { stateless: true, enable_json_response: true, max_request_bytes: MAX_REQUEST_BYTES }
52
- options[:allowed_hosts] = allowed_hosts if allowed_hosts && !allowed_hosts.empty?
53
- app = ::MCP::Server::Transports::StreamableHTTPTransport.new(server, **options)
54
- server.transport = app
55
- app
169
+ # +listen_options+ passes `max_listen_subscriptions:` /
170
+ # `listen_keepalive_interval:` through to the SDK real configuration
171
+ # for a caller composing the bridge directly; the CLI keeps the
172
+ # defaults above.
173
+ def app_for(server, bind:, allowed_hosts: allowed_hosts_for(bind), **listen_options)
174
+ App.transport(server, allowed_hosts: allowed_hosts,
175
+ max_listen_subscriptions: MAX_LISTEN_STREAMS, **listen_options)
56
176
  end
57
177
 
58
178
  # The Host allowlist a bind address needs: nil for loopback, which the
@@ -120,19 +240,24 @@ module OKF
120
240
  []
121
241
  end
122
242
 
123
- # Returns an unstarted server so tests can drive an ephemeral port.
243
+ # Returns an unstarted server so tests can drive an ephemeral port. The
244
+ # stream ledger rides the server instance — one per bridge, reachable
245
+ # from #stop — because the module itself serves many servers at once
246
+ # under the test suite.
124
247
  def build(app, bind:, port:)
248
+ streams = Streams.new
125
249
  httpd = WEBrick::HTTPServer.new(
126
250
  BindAddress: bind,
127
251
  Port: port,
128
252
  Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN),
129
253
  AccessLog: []
130
254
  )
131
- httpd.mount_proc("/") { |request, response| handle(app, request, response) }
255
+ httpd.config[:okf_mcp_streams] = streams
256
+ httpd.mount_proc("/") { |request, response| handle(app, request, response, streams) }
132
257
  httpd
133
258
  end
134
259
 
135
- def handle(app, request, response)
260
+ def handle(app, request, response, streams = nil)
136
261
  # The MCP endpoint is the root and nothing else. The SDK transport
137
262
  # routes on method alone, so handing it every path answered the OAuth
138
263
  # discovery probes a connecting host sends first (GET /.well-known/*,
@@ -154,11 +279,52 @@ module OKF
154
279
  status, headers, out = app.call(env_for(request, body))
155
280
  response.status = status
156
281
  headers.each { |name, value| response[name] = value }
157
- buffer = String.new
158
- out.each { |chunk| buffer << chunk }
159
- response.body = buffer
160
- ensure
161
- out.close if out.respond_to?(:close)
282
+ # A callable body is the Rack 3 streaming shape — the SDK's
283
+ # `subscriptions/listen` answers with one and buffering it here
284
+ # would block forever on a stream that only ends when the peer goes.
285
+ # The close is per-branch, not a method-level ensure: WEBrick invokes
286
+ # a streaming body only after this method returns, and a callable
287
+ # that also responds to close (a shape Rack 3 sanctions) must not be
288
+ # closed before it is served.
289
+ if out.respond_to?(:call)
290
+ stream_response(response, out, streams)
291
+ else
292
+ begin
293
+ buffer = String.new
294
+ out.each { |chunk| buffer << chunk }
295
+ response.body = buffer
296
+ ensure
297
+ out.close if out.respond_to?(:close)
298
+ end
299
+ end
300
+ end
301
+
302
+ # Serves a Rack streaming body through WEBrick's proc-body path: with
303
+ # `chunked = true`, WEBrick calls the proc with a ChunkedWrapper after
304
+ # the headers are out, and finalizes the response when it returns. The
305
+ # SDK's callable returns immediately (it registers the stream, writes
306
+ # the acknowledgement, and starts its keepalive thread), so the proc
307
+ # parks this handler thread in Stream#wait until the SDK ends the
308
+ # stream — a dead peer's EPIPE out of a keepalive write, or the
309
+ # transport's close on shutdown.
310
+ def stream_response(response, body, streams = nil)
311
+ response.keep_alive = false # an SSE stream ends with its connection
312
+ response.chunked = true
313
+ response.body = lambda do |wire|
314
+ stream = Stream.new(wire)
315
+ # Admitted before the SDK sees it: a stream arriving after the stop
316
+ # latch tripped is closed here and now, so the SDK's first write
317
+ # raises IOError into its own cleanup and this thread never parks.
318
+ streams.admit(stream) if streams
319
+ begin
320
+ body.call(stream)
321
+ stream.wait
322
+ ensure
323
+ stream.close # idempotent; covers a body that raised
324
+ streams.discard(stream) if streams
325
+ body.close if body.respond_to?(:close)
326
+ end
327
+ end
162
328
  end
163
329
 
164
330
  # The request body, or nil when it exceeds MAX_REQUEST_BYTES. A declared
@@ -253,6 +253,7 @@ module OKF
253
253
  def tags_tool(context)
254
254
  define_tool(
255
255
  name: "tags",
256
+ title: "Tag index",
256
257
  description: "The tag index: every tag with its count and concepts, ordered by count. " \
257
258
  "`by: \"dir\"` or `by: \"type\"` regroups per concept dimension for vocabulary " \
258
259
  "curation — each tag then carries `count` (within the group) beside `total` " \
@@ -285,6 +286,7 @@ module OKF
285
286
  def types_tool(context)
286
287
  define_tool(
287
288
  name: "types",
289
+ title: "Type index",
288
290
  description: "The type index: every type with its count and concepts, ordered by count. " \
289
291
  "§4.1's vocabulary is open — this is how you learn what a bundle's producer " \
290
292
  "meant by its types before filtering the catalog on one.",
@@ -309,6 +311,7 @@ module OKF
309
311
  def stats_tool(context)
310
312
  define_tool(
311
313
  name: "stats",
314
+ title: "Bundle stats",
312
315
  description: "Bundle rollups in one answer: concepts, dirs, types, cross-links, distinct " \
313
316
  "tags, and the by_type/by_dir/by_top_dir distributions — \"how big is what I am " \
314
317
  "about to read\". by_dir counts the file's directory (a dir holding nothing " \
@@ -378,6 +381,7 @@ module OKF
378
381
  def list_bundles_tool(context)
379
382
  define_tool(
380
383
  name: "list_bundles",
384
+ title: "List bundles",
381
385
  description: "What exists: every OKF bundle this server knows — slug, title, root, concept " \
382
386
  "count, type/tag rollups (top #{ROLLUP_LIMIT} each, with other_types/other_tags remainder " \
383
387
  "counts), which is the default, and whether its directory is missing — plus the " \
@@ -402,6 +406,7 @@ module OKF
402
406
  def dirs_tool(context)
403
407
  define_tool(
404
408
  name: "dirs",
409
+ title: "Directory tree",
405
410
  description: "The first move: a bundle's shape as one row per directory — `count` is the " \
406
411
  "concepts living directly there, `subtree` the weight at or below it, `subdirs` " \
407
412
  "its children. Orient here before anything else; it scales with the tree, not " \
@@ -429,6 +434,7 @@ module OKF
429
434
  def index_tool(context)
430
435
  define_tool(
431
436
  name: "index",
437
+ title: "Bundle index",
432
438
  description: "The bundle's index map, one directory at a time: the authored index.md body " \
433
439
  "(or `synthesized: true` where none exists), type/tag rollups, subdirectories, " \
434
440
  "and the concept listing an index there would enumerate — the view that surfaces " \
@@ -461,6 +467,7 @@ module OKF
461
467
  def search_tool(context)
462
468
  define_tool(
463
469
  name: "search",
470
+ title: "Search concepts",
464
471
  description: "Find concepts: every term must match (AND) across title, id, tags, type, " \
465
472
  "description, and body. Returns scored rows — each carrying its `bundle` and the " \
466
473
  "fields it `matched`, so results stay citable — with ids for read_concept. Omit " \
@@ -511,6 +518,7 @@ module OKF
511
518
  def read_concept_tool(context)
512
519
  define_tool(
513
520
  name: "read_concept",
521
+ title: "Read a concept",
514
522
  description: "Read one concept's full markdown (frontmatter and body), live from disk — the " \
515
523
  "canonical copy. Ids are exact: take them from search, index, or catalog results.",
516
524
  input_schema: {
@@ -545,6 +553,7 @@ module OKF
545
553
  def catalog_tool(context)
546
554
  define_tool(
547
555
  name: "catalog",
556
+ title: "Catalog",
548
557
  description: "Per-concept metadata for a whole bundle — #{CATALOG_FIELDS.join(", ")} — " \
549
558
  "filterable by type, dir (prefix: a dir names itself and everything beneath " \
550
559
  "it), tag, status (effective: absent reads stable) and trust tier. Use it for " \
@@ -586,6 +595,7 @@ module OKF
586
595
  def log_tool(context)
587
596
  define_tool(
588
597
  name: "log",
598
+ title: "Update log",
589
599
  description: "Read a bundle's append-only history — every log.md, root scope first, content " \
590
600
  "live from disk. Returns the newest #{LOG_LIMIT} date-grouped entries per file; " \
591
601
  "each file's `total` is how many it holds and `returned` how many came back, so " \
@@ -611,6 +621,7 @@ module OKF
611
621
  def validate_tool(context)
612
622
  define_tool(
613
623
  name: "validate",
624
+ title: "Validate conformance",
614
625
  description: "The spec §11 conformance verdict: `conformant` (hard errors empty), every error " \
615
626
  "and soft warning with its file and why — unopenable files included. Read-only: " \
616
627
  "you may flag what it finds; fixing it belongs to the okf skill and CLI. " \
@@ -636,6 +647,7 @@ module OKF
636
647
  def lint_tool(context)
637
648
  define_tool(
638
649
  name: "lint",
650
+ title: "Lint curation",
639
651
  description: "The curation-quality report — reachability, backlog, completeness, freshness, " \
640
652
  "provenance, attestation, migration, hygiene — as warnings and infos that never " \
641
653
  "reject (conformance is validate's question). `only`/`except` select by check id " \
@@ -667,6 +679,7 @@ module OKF
667
679
  def graph_tool(context)
668
680
  define_tool(
669
681
  name: "graph",
682
+ title: "Concept graph",
670
683
  description: "The knowledge graph, in three bounded views — never with concept bodies. " \
671
684
  "\"minimal\" (default): id/title nodes, edges, and the type/tag indexes — the " \
672
685
  "shape for planning a traversal. \"hubs\": concepts ranked by inbound links with " \
@@ -693,6 +706,7 @@ module OKF
693
706
  def references_tool(context)
694
707
  define_tool(
695
708
  name: "references",
709
+ title: "References inventory",
696
710
  description: "Inventory the bundle's references/ tree (§6.3): every file — the .py attesters " \
697
711
  "and .sql computations no other tool can see included — with the concepts citing " \
698
712
  "each through the §6.2 path-valued fields (resource, sources[].resource, " \
@@ -1000,9 +1014,10 @@ module OKF
1000
1014
  # rescue turns domain failures into tool errors an agent can act on
1001
1015
  # instead of opaque JSON-RPC internal errors — carrying the kernel's
1002
1016
  # own sentences, never re-judged here.
1003
- def define_tool(name:, description:, input_schema:, &body)
1017
+ def define_tool(name:, title:, description:, input_schema:, &body)
1004
1018
  ::MCP::Tool.define(
1005
1019
  name: name,
1020
+ title: title,
1006
1021
  description: description,
1007
1022
  # Looked up rather than passed, so a tool cannot ship without its
1008
1023
  # shape being a deliberate omission — read_concept is the one
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OKF
4
4
  module MCP
5
- VERSION = "1.1.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
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.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rodrigo Serradura
@@ -15,21 +15,21 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '1.0'
18
+ version: '1.2'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '1.0'
25
+ version: '1.2'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: okf
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '2.0'
32
+ version: '2.1'
33
33
  - - "<"
34
34
  - !ruby/object:Gem::Version
35
35
  version: '3'
@@ -39,7 +39,7 @@ dependencies:
39
39
  requirements:
40
40
  - - ">="
41
41
  - !ruby/object:Gem::Version
42
- version: '2.0'
42
+ version: '2.1'
43
43
  - - "<"
44
44
  - !ruby/object:Gem::Version
45
45
  version: '3'
@@ -61,6 +61,7 @@ files:
61
61
  - NOTICE
62
62
  - README.md
63
63
  - lib/okf/mcp.rb
64
+ - lib/okf/mcp/app.rb
64
65
  - lib/okf/mcp/backend.rb
65
66
  - lib/okf/mcp/cli.rb
66
67
  - lib/okf/mcp/filters.rb