okf-mcp 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ ---
2
+ type: Component
3
+ title: The served set, and the cache in front of it
4
+ description: Which bundles this server will answer about — resolved once at boot as the tools' allowlist — plus the folder cache, the engine detection and the one place the `dir` vocabulary lives.
5
+ tags: [mcp, registry, cache, backend, filters]
6
+ generated:
7
+ by: human:maintainer
8
+ at: 2026-08-19T12:00:00Z
9
+ resource: lib/okf/mcp/registry.rb
10
+ ---
11
+
12
+ # The files
13
+
14
+ | file | what it owns |
15
+ | ---- | ------------ |
16
+ | `lib/okf/mcp/registry.rb` | `OKF::MCP::Registry` — the served set: argv refs (dirs and `@slug`s) or the kernel registry, resolved once at boot |
17
+ | `lib/okf/mcp/filters.rb` | `OKF::MCP::Filters` — the `dir` vocabulary: folding, normalizing, containment, depth |
18
+ | `lib/okf/mcp/backend.rb` | `OKF::MCP::Backend` — engine detection: the in-memory backend, or `okf-sqlite3` when it is installed and suitable |
19
+ | `lib/okf/mcp/memory_backend.rb` | `OKF::MCP::MemoryBackend` — the always-present folder cache: residency, fingerprints, catalog rows, search pairs |
20
+
21
+ # The registry is an allowlist, not a lookup
22
+
23
+ `Registry.from_argv` and `Registry.from_kernel` resolve **once, at boot**. After
24
+ that the entry list is fixed, and `#root!` is the only way a tool turns a bundle
25
+ name into a path — it raises for anything not in the set.
26
+
27
+ That is the containment property the whole server rests on: a host cannot ask
28
+ this process to read a bundle nobody served it. `#refresh!` re-reads the same
29
+ sources; it does not widen the set.
30
+
31
+ Identity comes from the kernel's registry, never minted here when a kernel slug
32
+ exists — `resolve_arg`, `ref_entry` and `group_roots` all defer to it, so
33
+ `@handbook` means the same bundle to `okf lint` and to a host. Groups expand to
34
+ their member roots, recursively, at resolve time.
35
+
36
+ # The `dir` vocabulary lives in exactly one file
37
+
38
+ `Filters` exists because it did not. The folding, normalizing and containment
39
+ rules lived in two places and **the two copies disagreed about the root** —
40
+ one treated `""` as "everything", the other as "the root directory only". Every
41
+ tool that takes a `dir` now reads them from here.
42
+
43
+ `normalize_dir`, `within?`, `dir_depth` and `known_dir?` are the whole surface.
44
+ A new tool taking a `dir` uses them rather than writing a third opinion.
45
+
46
+ # The cache is per-root and fingerprinted
47
+
48
+ `MemoryBackend#folder` caches an `OKF::Bundle::Folder` per root;
49
+ `#fingerprint` is what decides whether a cached one is still good. `#retain`
50
+ prunes roots that are no longer served, and `#during_request` memoizes
51
+ fingerprints for the span of one request so a single frame does not stat the
52
+ same tree repeatedly.
53
+
54
+ `Backend.detect` is the seam for a faster engine: if `okf-sqlite3` is installed
55
+ and `suitable?`, it is used; otherwise the memory backend, which is always
56
+ present. Nothing above this layer knows which one answered:
57
+ [the server definition](server-definition.md) asks the backend, never the
58
+ engine.
@@ -0,0 +1,72 @@
1
+ ---
2
+ type: Component
3
+ title: The server definition
4
+ description: The MCP::Server subclass, the fourteen tool builders and the private helpers they share, one declared output shape per tool, and concepts as resources with a URI parser the SDK's matcher cannot supply.
5
+ tags: [mcp, server, tools, schemas, resources]
6
+ generated:
7
+ by: human:maintainer
8
+ at: 2026-08-19T12:00:00Z
9
+ resource: lib/okf/mcp/server.rb
10
+ ---
11
+
12
+ # The files
13
+
14
+ | file | what it owns |
15
+ | ---- | ------------ |
16
+ | `lib/okf/mcp/server.rb` | `OKF::MCP::Server` — `Server::Definition < ::MCP::Server`, `Server.build`, the fourteen `*_tool` builders, the two prompts, and the shared private helpers |
17
+ | `lib/okf/mcp/output_schemas.rb` | `OKF::MCP::OutputSchemas` — one declared result shape per tool, looked up by name |
18
+ | `lib/okf/mcp/resources.rb` | `OKF::MCP::Resources` — bundles and concepts as MCP resources, and the URI grammar |
19
+
20
+ # server.rb is one file on purpose, in three bands
21
+
22
+ At ~1,300 lines it is the largest file in the gem, and splitting it has been
23
+ considered and declined: the fourteen builders are near-identical in shape, and
24
+ what makes them safe is that they sit next to each other where a divergence is
25
+ visible. Read it as three bands.
26
+
27
+ **`Server.build` and `Definition`.** `build` assembles the definition from a
28
+ registry and an engine. `Definition` subclasses the SDK's server and adds the
29
+ per-request wrap: `#in_request` memoizes fingerprints and `#retain_served`
30
+ prunes cache residency, so a frame's repeated reads cost one stat. `#handle`
31
+ and `#handle_json` are what the tests drive — real JSON-RPC frames, no
32
+ transport involved.
33
+
34
+ **The fourteen builders**, one per tool: `tags_tool`, `types_tool`,
35
+ `stats_tool`, `list_bundles_tool`, `dirs_tool`, `index_tool`, `search_tool`,
36
+ `read_concept_tool`, `catalog_tool`, `log_tool`, `validate_tool`, `lint_tool`,
37
+ `graph_tool`, `references_tool`. Each is a `define_tool` call and a body that
38
+ calls the kernel. The [tool catalog](../capabilities/tools.md) is the reader's
39
+ map of them.
40
+
41
+ **The shared helpers**, which are where the contracts actually live:
42
+ `define_tool` (the one place `readOnlyHint`, `title` and the output-schema
43
+ lookup are attached — which is why a tool cannot forget them), `respond_json`
44
+ and `respond_error`, the projection and validation helpers (`check_fields`,
45
+ `check_projection`, `check_asked`, `project_rows`), the bounded-log arithmetic
46
+ (`bounded_log`, `sized_log`), and `check_dir!`, which is the refusal every
47
+ `dir`-taking tool shares.
48
+
49
+ # One declared shape per tool, looked up by name
50
+
51
+ `OutputSchemas[name]` is a lookup, not a registry a tool writes into. An
52
+ omission is therefore deliberate and visible: `read_concept` has no shape,
53
+ because it answers markdown rather than a structured row, and that is the only
54
+ one. `test/integration/output_schema_test.rb` walks the wire and checks the
55
+ declared shape against the answer actually sent.
56
+
57
+ # Resources own the URI grammar because the SDK cannot
58
+
59
+ `Resources.parse` exists for one reason worth knowing before touching it: **OKF
60
+ concept ids carry slashes** (`capabilities/graph-server.md`), and the SDK's
61
+ resource-template matcher stops at `[^/]+`. So a templated match would truncate
62
+ every nested id. This file parses the URI itself.
63
+
64
+ `list`, `templates`, `read` and `complete` are the surface; `concept_ids` and
65
+ `prefixed` back the completion. `test/integration/resources_test.rb` and
66
+ `completions_test.rb` drive them.
67
+
68
+ # The prompts are read at get-time
69
+
70
+ `Server::PROMPTS` names two markdown files shipped in `lib/okf/mcp/prompts/`,
71
+ and `Server.prompt_text` reads one when a host asks for it. Booting never pays
72
+ for prompt bodies, and editing a prompt does not need a code change.
@@ -0,0 +1,65 @@
1
+ ---
2
+ type: Playbook
3
+ title: Adding a tool, end to end
4
+ description: The walk a fifteenth tool owes — the question to answer before writing it, where the code goes, the four files that gain a test, and the three pins that will refuse it if a step is skipped.
5
+ tags: [testing, tools, playbook, contribution]
6
+ generated:
7
+ by: human:maintainer
8
+ at: 2026-08-19T12:00:00Z
9
+ resource: lib/okf/mcp/server.rb
10
+ ---
11
+
12
+ # First, the question that usually ends it
13
+
14
+ **Do the [fourteen](../capabilities/tools.md) already compose to this?**
15
+ `dirs` + `index` + `search` answer most retrieval between them, and tool-list
16
+ weight is a real cost on every host that connects. A tool that duplicates a
17
+ composition is a permanent tax for a one-time convenience.
18
+
19
+ Second question: **could the kernel answer it?** If it could, it should — see
20
+ [kernel-first](../design/kernel-first.md). A kernel method serves the CLI, the
21
+ graph server, the TUI and this shell; a tool here serves one.
22
+
23
+ If both answers are no, the walk is below.
24
+
25
+ # The walk
26
+
27
+ 1. **Write the failing test first**, in the folder that matches how the bundle
28
+ is named — `test/integration/by_dir/<tool>_test.rb` at minimum, and
29
+ `by_registry/` and `across_bundles/` if the tool takes a bundle ref or
30
+ several. Drive real JSON-RPC through `handle_json`. Run it: it must fail
31
+ because the tool does not exist, not because a fixture is missing.
32
+ 2. **Add the kernel call**, if the analysis is not already there. That is a
33
+ change to `okf`, with its own test, landing first.
34
+ 3. **Add the builder** — `<name>_tool(context)` in `lib/okf/mcp/server.rb`,
35
+ beside its neighbours, and list it in `tools_for`. Use `define_tool`, which
36
+ is what attaches `readOnlyHint`, the `title` and the output-schema lookup;
37
+ nothing else may construct a tool.
38
+ 4. **Declare the output shape** in `lib/okf/mcp/output_schemas.rb`, keyed by
39
+ the tool's name. Omit it only for a markdown answer, and expect to defend
40
+ the omission — `read_concept` is the only one today.
41
+ 5. **Carry `total`** if the answer is a list: how many rows matched *before*
42
+ any `limit`. No silent truncation, ever.
43
+ 6. **Reuse the `dir` vocabulary** from `lib/okf/mcp/filters.rb` if the tool
44
+ takes a `dir`, and `check_dir!` for the refusal. Do not write a third
45
+ opinion about what the root means — that bug has already happened once.
46
+ 7. **Update [the catalog](../capabilities/tools.md)** with the tool's row.
47
+ 8. **Run the suite.** The same test from step 1 passes, unedited.
48
+
49
+ # The three pins that refuse a skipped step
50
+
51
+ - `test/integration/capabilities_test.rb` reads the wire and fails a tool
52
+ missing `readOnlyHint` or `title`.
53
+ - `test/integration/output_schema_test.rb` checks each declared shape against
54
+ the answer actually sent.
55
+ - `test/unit/bundle_catalog_test.rb` fails when the catalog and `server.rb`
56
+ disagree about which tools exist — so step 7 is not optional, and forgetting
57
+ it is a red suite rather than a stale document.
58
+
59
+ # Adding a file, not a tool
60
+
61
+ A new file under `lib/` needs a line in whichever
62
+ [structure](../structure/) concept owns its layer — or a concept of its own if
63
+ it is a new layer. `bundle_catalog_test.rb` fails on an unowned file, which is
64
+ the point: the Map used to live in `AGENTS.md` where nothing checked it, and it
65
+ went stale silently.
@@ -0,0 +1,9 @@
1
+ # Testing
2
+
3
+ How this gem is tested, and what a change owes before it lands. The root
4
+ `AGENTS.md`'s rule applies here as written — a change starts with a failing
5
+ test, red for the reason you predicted, then the code, then the same test green
6
+ and unedited — and what follows is that rule's shape for this gem's surfaces.
7
+
8
+ * [The layers](layers.md) - What each layer of the suite proves, and the claims it deliberately does not spend a process on.
9
+ * [Adding a tool](adding-a-tool.md) - The walk a new tool owes: where the code goes, which files gain a test, and what the pins will refuse.
@@ -0,0 +1,56 @@
1
+ ---
2
+ type: Constraint
3
+ title: What each test layer proves
4
+ description: Integration-first over real JSON-RPC frames, with process-spawning confined to the two files that prove the process — plus the wire-reading rules the SSE tests cannot be written without.
5
+ tags: [testing, integration, http, sse]
6
+ generated:
7
+ by: human:maintainer
8
+ at: 2026-08-19T12:00:00Z
9
+ resource: test/integration
10
+ ---
11
+
12
+ # The layers
13
+
14
+ | layer | proves | files |
15
+ | ----- | ------ | ----- |
16
+ | tool integration | a tool's real answer, driven as JSON-RPC through `handle_json` | `test/integration/{by_dir,by_registry,across_bundles}/` |
17
+ | protocol surface | capabilities, output schemas, resources, completions, prompts | `capabilities_test.rb`, `output_schema_test.rb`, `resources_test.rb`, `completions_test.rb`, `prompts_test.rb` |
18
+ | the process | `okf mcp` spawned for real, WEBrick on a real socket | `cli_plugin_test.rb`, `http_test.rb`, `http_modern_test.rb`, `http_listen_test.rb` |
19
+ | the argv shell | flags and exit codes, in-process, spawning nothing | `cli_test.rb` |
20
+ | units | the pieces with contracts of their own | `test/unit/` |
21
+
22
+ The three tool directories mirror the kernel's own — the three ways a bundle is
23
+ named: by directory, through the registry, and several at once.
24
+
25
+ # One claim, one place
26
+
27
+ A claim about **argv** is proven once, cheaply, in `cli_test.rb`, which drives
28
+ the shell in-process and deliberately spawns nothing. A claim about **the
29
+ process** is proven once, in the spawning files. The split is deliberate: every
30
+ spawn is seconds, and a suite that spawns for argv claims spends minutes
31
+ proving something a method call already settled.
32
+
33
+ The three HTTP files share one harness, `test/integration/http_harness.rb`, so
34
+ they cannot drift in how they compose the bridge — the same reason
35
+ [`App`](../structure/doors.md) is a single construction site in `lib/`.
36
+
37
+ # Reading the wire, which is where SSE tests go wrong
38
+
39
+ Two rules, both learned the hard way, and neither optional:
40
+
41
+ **Use a raw `TCPSocket`, not `Net::HTTP`.** Net::HTTP holds a chunked body
42
+ until EOF, so a stream that stays open — which is the whole point of
43
+ `subscriptions/listen` — reads as a hang rather than as a passing test.
44
+
45
+ **Thread `read_until`'s returned buffer back in.** One `readpartial` can carry
46
+ the next frame along with the awaited one, so a sequential read that starts
47
+ from an empty buffer drops a frame and then blocks waiting for it.
48
+
49
+ # The bundle is pinned too
50
+
51
+ `test/unit/bundle_catalog_test.rb` checks this bundle against the tree: every
52
+ `.rb` under `lib/` named by exactly one concept in
53
+ [`structure/`](../structure/), every path a concept names still present, and
54
+ the [tool catalog](../capabilities/tools.md) agreeing with the tools
55
+ `server.rb` defines. Structural documentation is code-derived, so it is pinned
56
+ like code rather than trusted like prose.
data/CHANGELOG.md CHANGED
@@ -5,6 +5,108 @@ 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
+ ## [1.2.1] - 2026-08-20
9
+
10
+ ### Fixed
11
+
12
+ - **The package metadata follows the `gems/` move and the repository rename.**
13
+ Two URLs on the package page were about to be wrong at once. `changelog_uri`
14
+ named `blob/main/okf-mcp/CHANGELOG.md`, a path that stopped existing when the
15
+ four gems moved under `gems/`; `homepage` named `serradura/okf-gem`, and the
16
+ repository is now **`serradura/okf`** — the name the command has had all
17
+ along. rubygems.org serves whatever the last release published, so neither
18
+ corrects itself: only a release republishes metadata, and this is that
19
+ release. Both land in one round rather than two, which is the whole reason
20
+ the rename waited for the paths to stop moving.
21
+
22
+ `homepage_uri`, `source_code_uri` and `changelog_uri` are all derived from
23
+ `homepage`, so one line moves four pieces of this gem's metadata. GitHub
24
+ redirects the old URLs permanently, so anything already published keeps
25
+ resolving.
26
+
27
+ ### Added
28
+
29
+ - **The gem ships its own knowledge bundle.** `.okf/` is in `spec.files`, so an
30
+ installed okf-mcp carries a real bundle — its own — that a host can read
31
+ through the very tools it serves. It documents the gem's structure: every
32
+ file under `lib/` grouped by the layer that owns it, the catalog of the
33
+ fourteen tools, the four design rules, and how to add a tool or a test.
34
+ `AGENTS.md` now relies on it rather than restating it, and
35
+ `test/unit/bundle_catalog_test.rb` fails when the two disagree with the tree
36
+ — a file no concept names, a concept naming a file that is gone, or a tool
37
+ catalog out of step with `server.rb`.
38
+
39
+ ### Fixed
40
+
41
+ - **`rake test` on the 2.7 floor.** The Rakefile set
42
+ `Bundler::GemHelper#tag_prefix=` unguarded, and that accessor arrived in
43
+ Bundler 2.2 — Ruby 2.7, this gem's floor, ships 2.1.4. Loading the Rakefile
44
+ there raised `NoMethodError` and took the whole task down before a single
45
+ test ran. CI never saw it, because `ruby/setup-ruby` installs the newest
46
+ Bundler each Ruby accepts; only a floor run against the stock image
47
+ surfaces it. okf-tui and okf-pro have carried the `respond_to?` guard since
48
+ they were cut, and this gem now does too — on a Bundler without the
49
+ accessor, `release` aborts rather than pushing a bare `vX.Y.Z` that would
50
+ trigger the okf image build.
51
+
52
+ ### Changed
53
+
54
+ - **The okf floor moves to `>= 2.1.1, < 3`** — the kernel this shell develops
55
+ against released 2.1.1, and `test/unit/gemspec_test.rb` holds the floor to the
56
+ version the suite actually resolves against. It may lead the kernel; it may
57
+ never lag. The ceiling is unchanged.
58
+
59
+ ## [1.2.0] - 2026-08-18
60
+
61
+ ### Added
62
+
63
+ - **`OKF::MCP.app` — the Rack seam.** The same server definition and
64
+ stateless transport `okf mcp --http` serves, as the app a `config.ru`
65
+ runs: `run OKF::MCP.app` serves the registered bundles under puma,
66
+ unicorn, or any Rack 3 server, with argv-shaped refs and
67
+ `allowed_hosts:`/`allowed_origins:` for a reverse proxy. The server you
68
+ mount it under is your dependency, not this gem's — the no-rackup
69
+ position holds, and `--http` on the kernel's WEBrick stays the
70
+ zero-config default. The entry is lazy and a loading test pins it:
71
+ `require "okf/mcp"` still loads neither the SDK nor WEBrick.
72
+
73
+ - **Every tool carries a `title`** — the human name a host displays where it
74
+ lists tools ("Search concepts", "Validate conformance"), beside the wire
75
+ name an agent calls. All fourteen or none, and a capabilities test pins
76
+ all fourteen: a mix of titled and untitled tools reads as a half-finished
77
+ server in a Desktop listing.
78
+
79
+ ### Fixed
80
+
81
+ - **`subscriptions/listen` streams through the WEBrick bridge** instead of
82
+ crashing it. The SDK answers the modern lifecycle's listen with a Rack
83
+ streaming body — a callable, not an enumerable — and the bridge's buffering
84
+ loop raised `NoMethodError`, handing the client WEBrick's HTML 500 page
85
+ with the exception text in it: a broken server where the spec's
86
+ notification stream should open. The bridge now serves callable bodies over
87
+ a chunked response, parking the handler thread until the SDK ends the
88
+ stream (a dead peer's `EPIPE`, or the transport's close at shutdown — which
89
+ now runs before WEBrick's, whose thread-join would otherwise hang on an
90
+ open stream). Concurrent listens are capped at 32 on this bridge — each one
91
+ holds a WEBrick thread and connection token, unlike under a Rack 3 server —
92
+ and since this server declares no `listChanged` or `subscribe` capability,
93
+ the acknowledged filter is always empty: a stream carries keepalives, never
94
+ a notification, and that is the conformant answer.
95
+
96
+ ### Changed
97
+
98
+ - **The mcp floor moves to `~> 1.2`** — the SDK this shell rides released
99
+ 1.2.0, completing the SEP-2575 stateless modern lifecycle of the
100
+ 2026-07-28 protocol, and the suite now proves wire behavior (the modern
101
+ envelope, `server/discover`, the streamed `subscriptions/listen`) that
102
+ 1.0 and 1.1 never served. The floor tracks what the suite proves, and a
103
+ new unit guard pins it the same way the okf floor is pinned: a lockfile
104
+ that resolves past the floor fails the suite until the gemspec follows.
105
+ - **The okf floor moves to `>= 2.1, < 3`** — the kernel this gem develops
106
+ against released 2.1.0, and the floor tracks what the suite proves against
107
+ (the gemspec drill enforces equality as the normal state). Nothing here calls
108
+ a 2.1-only surface; the ceiling is unchanged.
109
+
8
110
  ## [1.1.0] - 2026-08-14
9
111
 
10
112
  ### Added
@@ -291,6 +393,8 @@ rather than pretending to be changes somebody could have seen.
291
393
 
292
394
  The name reservation on RubyGems: an empty gem, no functionality.
293
395
 
294
- [1.1.0]: https://github.com/serradura/okf-gem/compare/okf-mcp/v1.0.0...okf-mcp/v1.1.0
295
- [1.0.0]: https://github.com/serradura/okf-gem/releases/tag/okf-mcp/v1.0.0
396
+ [1.2.1]: https://github.com/serradura/okf/compare/okf-mcp/v1.2.0...okf-mcp/v1.2.1
397
+ [1.2.0]: https://github.com/serradura/okf/compare/okf-mcp/v1.1.0...okf-mcp/v1.2.0
398
+ [1.1.0]: https://github.com/serradura/okf/compare/okf-mcp/v1.0.0...okf-mcp/v1.1.0
399
+ [1.0.0]: https://github.com/serradura/okf/releases/tag/okf-mcp/v1.0.0
296
400
  [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.
@@ -186,6 +206,16 @@ bundle exec rake # tests + RuboCop — what CI runs
186
206
  bundle exec rake test:integration # the critical layer alone + coverage/integration/
187
207
  ```
188
208
 
209
+ The gem carries its own OKF bundle in `.okf/` — what each file under `lib/`
210
+ does, the catalog of the fourteen tools, the design rules, and how to add a
211
+ tool. It ships inside the gem, so it is there after `gem install` too, and the
212
+ quickest way to read it is through this server:
213
+
214
+ ```bash
215
+ okf mcp .okf # serve okf-mcp's own knowledge, over MCP
216
+ okf server .okf # or read it as a graph
217
+ ```
218
+
189
219
  ## License
190
220
 
191
221
  Apache-2.0, see [LICENSE.txt](LICENSE.txt).
@@ -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