okf-mcp 1.1.0 → 1.2.1

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.
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.1"
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.1
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.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.1
43
43
  - - "<"
44
44
  - !ruby/object:Gem::Version
45
45
  version: '3'
@@ -56,11 +56,33 @@ executables: []
56
56
  extensions: []
57
57
  extra_rdoc_files: []
58
58
  files:
59
+ - ".okf/capabilities/index.md"
60
+ - ".okf/capabilities/resources-and-prompts.md"
61
+ - ".okf/capabilities/tools.md"
62
+ - ".okf/capabilities/transports.md"
63
+ - ".okf/design/index.md"
64
+ - ".okf/design/kernel-first.md"
65
+ - ".okf/design/one-entry-point.md"
66
+ - ".okf/design/ruby-floor.md"
67
+ - ".okf/design/runtime-dependencies.md"
68
+ - ".okf/design/the-tool-set.md"
69
+ - ".okf/index.md"
70
+ - ".okf/log.md"
71
+ - ".okf/overview.md"
72
+ - ".okf/structure/doors.md"
73
+ - ".okf/structure/http-bridge.md"
74
+ - ".okf/structure/index.md"
75
+ - ".okf/structure/served-set.md"
76
+ - ".okf/structure/server-definition.md"
77
+ - ".okf/testing/adding-a-tool.md"
78
+ - ".okf/testing/index.md"
79
+ - ".okf/testing/layers.md"
59
80
  - CHANGELOG.md
60
81
  - LICENSE.txt
61
82
  - NOTICE
62
83
  - README.md
63
84
  - lib/okf/mcp.rb
85
+ - lib/okf/mcp/app.rb
64
86
  - lib/okf/mcp/backend.rb
65
87
  - lib/okf/mcp/cli.rb
66
88
  - lib/okf/mcp/filters.rb
@@ -74,14 +96,14 @@ files:
74
96
  - lib/okf/mcp/server.rb
75
97
  - lib/okf/mcp/version.rb
76
98
  - lib/okf/plugin.rb
77
- homepage: https://github.com/serradura/okf-gem
99
+ homepage: https://github.com/serradura/okf
78
100
  licenses:
79
101
  - Apache-2.0
80
102
  metadata:
81
103
  allowed_push_host: https://rubygems.org
82
- homepage_uri: https://github.com/serradura/okf-gem
83
- source_code_uri: https://github.com/serradura/okf-gem
84
- changelog_uri: https://github.com/serradura/okf-gem/blob/main/okf-mcp/CHANGELOG.md
104
+ homepage_uri: https://github.com/serradura/okf
105
+ source_code_uri: https://github.com/serradura/okf
106
+ changelog_uri: https://github.com/serradura/okf/blob/main/gems/okf-mcp/CHANGELOG.md
85
107
  rubygems_mfa_required: 'true'
86
108
  rdoc_options: []
87
109
  require_paths: