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 +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +20 -0
- data/lib/okf/mcp/app.rb +91 -0
- data/lib/okf/mcp/http.rb +186 -20
- data/lib/okf/mcp/server.rb +16 -1
- data/lib/okf/mcp/version.rb +1 -1
- data/lib/okf/mcp.rb +8 -0
- metadata +6 -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,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.
|
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/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
|
data/lib/okf/mcp/server.rb
CHANGED
|
@@ -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
|
data/lib/okf/mcp/version.rb
CHANGED
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|