okf-mcp 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +133 -0
- data/README.md +38 -7
- data/lib/okf/mcp/app.rb +91 -0
- data/lib/okf/mcp/cli.rb +1 -1
- data/lib/okf/mcp/filters.rb +9 -11
- data/lib/okf/mcp/http.rb +186 -20
- data/lib/okf/mcp/memory_backend.rb +12 -8
- data/lib/okf/mcp/output_schemas.rb +37 -1
- data/lib/okf/mcp/prompts/search.md +2 -2
- data/lib/okf/mcp/server.rb +306 -39
- data/lib/okf/mcp/version.rb +1 -1
- data/lib/okf/mcp.rb +8 -0
- metadata +12 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a730c3c4e2bc94cfdc83704f96ae40425cce8e561e8bb77f84abd153607c81d3
|
|
4
|
+
data.tar.gz: a1651babe0d4dd972d0d83590aafddba43f411a7f70f2047b92933b8f4dbc281
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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,
|
|
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 §
|
|
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
|
data/lib/okf/mcp/app.rb
ADDED
|
@@ -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
|
data/lib/okf/mcp/filters.rb
CHANGED
|
@@ -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
|
-
#
|
|
16
|
-
#
|
|
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 "
|
|
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
|
|
29
|
-
#
|
|
30
|
-
# before it is allocated
|
|
31
|
-
MAX_REQUEST_BYTES =
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|