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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 16fa224a5cb1c83ae561094458afe95726a3f04c94a35e56db94b260a2a213bc
4
- data.tar.gz: 06027d6762f7f35510d22fc124ecd8e473249bb492baea09238fcd23dafd01a1
3
+ metadata.gz: a730c3c4e2bc94cfdc83704f96ae40425cce8e561e8bb77f84abd153607c81d3
4
+ data.tar.gz: a1651babe0d4dd972d0d83590aafddba43f411a7f70f2047b92933b8f4dbc281
5
5
  SHA512:
6
- metadata.gz: 7e37dd09203d6ace0e2a6394a3efdc8551bc3f74bd0e53c7e30c27795cbb7f9fa3975bb9d5697bc4afdcabbef075300386362a0f4f7f79d7ec863beb638f31bb
7
- data.tar.gz: 4c82c6fdf6fe68a04cc109d4ed3187606f6e276cdfc0cbf0368680d36cd2e2f67c05062304e3698cd473c739de5ac7089c2856c612c742718f5a9e0c251636e6
6
+ metadata.gz: d979addc867e9ee49f0fe3a5090915c63b3b9da55d92e89863e461022ce1316e110e80f5b4ceee6f4076a5171be3fad3691aa10fd732144b6e0dcf5ec0711c25
7
+ data.tar.gz: 4b52f99b0b429c500acd8fc2af91614f8a0dad69f6d8877533a701aa66421cac9cf1351e35b7b4eaa463e06a8af86506cb8af366cf21e4d6ebc2a74dea3ee8ce
data/CHANGELOG.md CHANGED
@@ -5,6 +5,137 @@ 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
+
61
+ ## [1.1.0] - 2026-08-14
62
+
63
+ ### Added
64
+
65
+ - **`tags`, `types` and `stats` — the CLI's remaining read views, as tools.** `tags`
66
+ ships with the `by: "dir" | "type"` curation view — the kernel's
67
+ `Bundle#tag_groups`, extracted so the within-group-beside-total counting
68
+ has one home — and `stats` reads the kernel's new `Bundle#stats`, whose
69
+ `by_dir` keeps the honest zero a directory holding nothing directly
70
+ reports. `files` is deliberately not a tool: `index`'s per-directory
71
+ listing and `catalog`'s projection already carry its whole answer, and
72
+ tool-list weight is a real cost on hosts.
73
+ - **`lint` takes `today`** — the CLI's `--today`, in the same deliberately
74
+ narrow calendar-day grammar (a reinterpretable `20260101` or a
75
+ `2026-02-30` is refused by name), so an `expired` report is reproducible
76
+ and citable. The wall clock stays the default.
77
+ - **`fields`/`except` projection on `search`, and `except` on `catalog`** —
78
+ the row-key vocabulary is checked either way, so a typo is refused by
79
+ name even when a query matches nothing; the pair is mutually exclusive.
80
+ - **`references`, the eleventh tool** — the kernel's §6.3 inventory over MCP,
81
+ and the one lens that sees a bundle's non-markdown files: every
82
+ `references/` entry with the concepts citing it through the §6.2
83
+ path-valued fields, plus every pointer that resolves to nothing (a bare
84
+ `references/…` from a subdirectory is the classic miss; the dangling entry
85
+ names the leading-slash fix). Advisory like every read tool — dangling
86
+ pointers are data, never a tool error.
87
+ - **`graph`'s traffic view takes `cut`** — the CLI's `--cut N`, so a client
88
+ can widen past the fitted threshold or narrow below it. Outside
89
+ `view: "traffic"` it is refused: a half-honored argument is the
90
+ silent-wrong-answer shape.
91
+
92
+ ### Changed
93
+
94
+ - **`search` narrows by `status` and `trust`**, the two filters `catalog` and
95
+ `okf search` already took. An agent that learned the vocabulary from either
96
+ one and brought it here got an invalid-argument error. They resolve through
97
+ the catalog rather than the engine's rows — a search row carries what the
98
+ engine matched on, and the §5 families are not among it.
99
+
100
+ - **`lint`'s `stale_after` takes the kernel's cutoff grammar** — a
101
+ `YYYY-MM-DD` date or a full timestamp — rather than everything
102
+ `Date.iso8601` parses. `20260101` and `2026-W01-1` are now tool errors
103
+ naming the accepted shapes; a `generated.at`-shaped timestamp is accepted,
104
+ as before, and reduced to its date. They were accepted before
105
+ and silently reinterpreted — `okf lint --stale-after 20260101` exits 2, so
106
+ the shell and its own CLI answered differently about one bundle's staleness.
107
+
108
+ - **The okf floor must move to okf's v0.2 release number in the release that
109
+ ships this** — the shell now reads v0.2 surfaces (the trust catalog column,
110
+ `Concept.effective_status`/`fold_tier`, the linter's clock contract), none
111
+ of which exist in published okf 1.13.0; the gemspec carries the same
112
+ RELEASE OBLIGATION note, and the floor cannot move sooner because the
113
+ monorepo resolves against the path-sourced okf still versioned 1.13.0.
114
+ `rake release` now refuses while the floor still admits 1.13.0 (the guard
115
+ hangs off `release:guard_clean`, ahead of both pushes, and deliberately not
116
+ off `build` — `rake install` depends on that), so the obligation stops a
117
+ release rather than riding inside one.
118
+ - The catalog tool speaks v0.2: `CATALOG_FIELDS` matches the new row
119
+ (`timestamp` is gone — the vocabulary refuses it by name), `status` narrows
120
+ on the effective value through the kernel's own fold, and a `trust` filter
121
+ lands beside it. `lint` supplies the clock the `expired` check needs, the
122
+ way the CLI does, and the client-visible descriptions and prompts cite
123
+ v0.2's chapter numbers.
124
+
125
+ ### Fixed
126
+
127
+ - **`search`'s declared output schema now carries `skipped`** — the field the
128
+ tool emits when `"*"` forgives a vanished bundle. A host typing its
129
+ structured-content handling off `outputSchema` dropped it, and the SDK's
130
+ result validation (on in the test suite) failed a payload the tool
131
+ genuinely produces; slugs, not rows, and never required.
132
+ - **An out-of-range `--port` or unresolvable `--bind` is a usage error.** The
133
+ socket layer raises `SocketError` (Socket::ResolutionError on newer
134
+ Rubies), which the boot rescue did not name, so a typo came back as a
135
+ backtrace and exit 1 instead of the one readable line and exit 2 every
136
+ other boot failure earns.
137
+
138
+
8
139
  ## [1.0.0] - 2026-08-07
9
140
 
10
141
  The first functional release: an MCP server over the okf kernel, judged by
@@ -213,5 +344,7 @@ rather than pretending to be changes somebody could have seen.
213
344
 
214
345
  The name reservation on RubyGems: an empty gem, no functionality.
215
346
 
347
+ [1.2.0]: https://github.com/serradura/okf-gem/compare/okf-mcp/v1.1.0...okf-mcp/v1.2.0
348
+ [1.1.0]: https://github.com/serradura/okf-gem/compare/okf-mcp/v1.0.0...okf-mcp/v1.1.0
216
349
  [1.0.0]: https://github.com/serradura/okf-gem/releases/tag/okf-mcp/v1.0.0
217
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.
@@ -91,27 +111,38 @@ to pin the set instead.
91
111
 
92
112
  ## Tools
93
113
 
94
- Ten read-only tools. Every list answer is bounded and names the full count it
95
- was cut from — the rows the request matched, before any `limit`:
114
+ Fourteen read-only tools. Every list answer is bounded and names the full
115
+ count it was cut from — the rows the request matched, before any `limit`:
96
116
 
97
117
  | Tool | What it answers |
98
118
  |---|---|
99
119
  | `list_bundles` | what exists: slug, title, root, concept count, type/tag rollups, the default, the groups, the backend |
100
120
  | `dirs` | the shape — one row per directory with direct and subtree counts; **the first move** |
101
121
  | `index` | the index map: authored index bodies, rollups, listings, one directory at a time (depth 1 by default) |
102
- | `search` | pointed questions: ANDed terms, scored rows carrying the fields they matched, across several bundles at once |
122
+ | `search` | pointed questions: ANDed terms, scored rows carrying the fields they matched, across several bundles at once; narrows by `type`, `dir`, `tag`, `status`, `trust`; `fields`/`except` project the rows |
103
123
  | `read_concept` | one concept's file, verbatim and live from disk; ids are exact |
104
- | `catalog` | per-concept metadata with link degrees; filters, paging, field projection |
124
+ | `catalog` | per-concept metadata with link degrees; the same five filters, paging, `fields`/`except` projection |
105
125
  | `log` | every `log.md`, root first, live — the newest 3 dated entries per file, each answer held to a byte budget `limit` scales; a cut says `truncated` |
106
- | `validate` | the spec §9 conformance verdict |
107
- | `lint` | the curation-quality report; `group: "folder"` lists the unlinked files by folder |
108
- | `graph` | the knowledge graph in three bounded views: minimal, hubs, traffic — never with bodies |
126
+ | `validate` | the spec §11 conformance verdict |
127
+ | `lint` | the curation-quality report; `group: "folder"` lists the unlinked files by folder; `today` pins the clock for a reproducible `expired` report |
128
+ | `graph` | the knowledge graph in three bounded views: minimal, hubs, traffic (`cut` widens or narrows the arcs) — never with bodies |
129
+ | `references` | the §6.3 inventory: every `references/` file — `.py` attesters and `.sql` computations included — with its citing concepts, and the pointers that resolve to nothing |
130
+ | `tags` | the tag index by count; `by: "dir"` or `"type"` regroups for vocabulary curation, within-group counts beside cross-bundle totals |
131
+ | `types` | the type index by count — how you learn what a producer meant by its open §4.1 vocabulary |
132
+ | `stats` | the sizing rollup: concepts, dirs, cross-links, distinct tags, and the by_type/by_dir/by_top_dir distributions |
109
133
 
110
134
  Each returns its JSON twice: as text, and as `structuredContent` against a
111
135
  declared `outputSchema`, so a host consumes a result without parsing a blob and
112
136
  guessing at its shape. `read_concept` is the exception — markdown has no object
113
137
  shape to declare.
114
138
 
139
+ The `status` and `trust` filters answer with the §5 families' vocabulary
140
+ (`draft | stable | deprecated`; `unverified | machine-confirmed |
141
+ human-reviewed`, either spelling). On `search` they narrow through the
142
+ catalog rather than the engine's rows — a search row carries what the engine
143
+ matched on, and the §5 families are not among it — so a `trust` that narrows
144
+ `catalog` narrows `search` identically.
145
+
115
146
  ## Prompts
116
147
 
117
148
  Two prompts, the consuming pair: `okf-search` — retrieval as progressive
@@ -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/cli.rb CHANGED
@@ -62,7 +62,7 @@ module OKF
62
62
  server = Server.build(registry, engine: engine)
63
63
  announce(registry, engine)
64
64
  prepare_http(server) if @http
65
- rescue Error, OKF::Error, OptionParser::ParseError, SystemCallError => e
65
+ rescue Error, OKF::Error, OptionParser::ParseError, SystemCallError, SocketError => e
66
66
  say("okf-mcp: #{e.message}")
67
67
  say(USAGE)
68
68
  return 2
@@ -10,13 +10,18 @@ module OKF
10
10
  # reported an empty bundle root while `search` and `dirs` answered for it.
11
11
  # Two copies of a rule is two answers waiting to disagree, so there is one.
12
12
  #
13
- # There are genuinely **two** rules here, and the distinction is not drift:
13
+ # There are genuinely **two** rules here, and the distinction is not drift.
14
+ # Only one of them still lives in this file:
14
15
  #
15
- # #under_dir? filters *concepts*. A dir names itself and everything
16
- # beneath it; `.` is a prefix of nothing, so the root
16
+ # concepts are filtered by `Bundle::RowFilter`, the kernel's one
17
+ # catalog-row predicate a dir names itself and everything
18
+ # beneath it, and `.` is a prefix of nothing, so the root
17
19
  # selects only what lives directly in it (the CLI's `--dir`).
20
+ # This module's job there is #normalize_dir, the spelling
21
+ # fold the kernel deliberately does not do.
18
22
  # #within? scopes a *tree* of directory rows, where the root is the
19
- # ancestor of every row (`dirs`/`index` walking down).
23
+ # ancestor of every row (`dirs`/`index` walking down). The
24
+ # kernel has no view of a directory tree, so this one stays.
20
25
  module Filters
21
26
  module_function
22
27
 
@@ -48,13 +53,6 @@ module OKF
48
53
  folded.empty? || folded == "." ? "." : folded
49
54
  end
50
55
 
51
- # Concept filtering: `dir` names itself and everything beneath it.
52
- def under_dir?(entry_dir, wanted)
53
- entry = fold(entry_dir)
54
- path = normalize_dir(wanted)
55
- entry == path || entry.start_with?("#{path}/")
56
- end
57
-
58
56
  # Tree scoping: the root is the ancestor of every row, so `.` selects
59
57
  # the whole tree rather than only what sits directly in it.
60
58
  def within?(entry_dir, base)
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