mcp_toolkit 0.3.0 → 0.4.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +201 -2
  3. data/README.md +315 -34
  4. data/config/routes.rb +19 -4
  5. data/lib/mcp_toolkit/auth/authority.rb +2 -2
  6. data/lib/mcp_toolkit/authority/composite_tool_provider.rb +32 -0
  7. data/lib/mcp_toolkit/authority/context.rb +45 -0
  8. data/lib/mcp_toolkit/authority/controller_methods.rb +408 -0
  9. data/lib/mcp_toolkit/authority/registry_tool_provider.rb +70 -0
  10. data/lib/mcp_toolkit/authority/token.rb +124 -0
  11. data/lib/mcp_toolkit/authority/tools/base.rb +122 -0
  12. data/lib/mcp_toolkit/authority/tools/get.rb +58 -0
  13. data/lib/mcp_toolkit/authority/tools/list.rb +84 -0
  14. data/lib/mcp_toolkit/authority/tools/resource_schema.rb +44 -0
  15. data/lib/mcp_toolkit/authority/tools/resources.rb +33 -0
  16. data/lib/mcp_toolkit/authority.rb +24 -0
  17. data/lib/mcp_toolkit/configuration.rb +205 -2
  18. data/lib/mcp_toolkit/dispatcher.rb +234 -0
  19. data/lib/mcp_toolkit/engine.rb +22 -7
  20. data/lib/mcp_toolkit/engine_controllers.rb +116 -0
  21. data/lib/mcp_toolkit/gateway/aggregator.rb +122 -0
  22. data/lib/mcp_toolkit/gateway/client.rb +305 -0
  23. data/lib/mcp_toolkit/gateway/proxy.rb +70 -0
  24. data/lib/mcp_toolkit/gateway/unknown_upstream.rb +8 -0
  25. data/lib/mcp_toolkit/gateway/upstream_call_error.rb +23 -0
  26. data/lib/mcp_toolkit/gateway/upstream_registry.rb +79 -0
  27. data/lib/mcp_toolkit/list_executor.rb +17 -0
  28. data/lib/mcp_toolkit/protocol.rb +106 -0
  29. data/lib/mcp_toolkit/rate_limiter.rb +73 -0
  30. data/lib/mcp_toolkit/registry.rb +30 -2
  31. data/lib/mcp_toolkit/resource.rb +125 -7
  32. data/lib/mcp_toolkit/resource_schema.rb +14 -1
  33. data/lib/mcp_toolkit/serializer/base.rb +2 -2
  34. data/lib/mcp_toolkit/session.rb +17 -9
  35. data/lib/mcp_toolkit/tools/authority_base.rb +127 -0
  36. data/lib/mcp_toolkit/transport/controller_methods.rb +2 -2
  37. data/lib/mcp_toolkit/usage_metering/recorder.rb +95 -0
  38. data/lib/mcp_toolkit/version.rb +1 -1
  39. data/lib/mcp_toolkit.rb +16 -3
  40. metadata +38 -2
  41. data/app/controllers/mcp_toolkit/server_controller.rb +0 -19
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f6ccc255fce267fe8c0caafe02f29e8a5db48e9b410494ef463bf9520047cbe2
4
- data.tar.gz: feb8b4e46f7e7054abef0bf99ad8d633ebb30d1fb36624b350313d35e83824f9
3
+ metadata.gz: e99039b1f2109b3da40c5d271338fbefc565bce7fb472b36947caf322d0ab3f8
4
+ data.tar.gz: db79d656f89ce7566e5437956a8f0feac0ba9f4a906896078db3cee4879b512b
5
5
  SHA512:
6
- metadata.gz: 50cc58bd25162c2ecf70b97b90b1de329a8b3858494dc719699d8cf609607d3c6f8abacef5c1ed1f0e0eea8b7dc9760c0a4752a816433e65ee661147404a53d8
7
- data.tar.gz: 72474820f84e31bf3601d5ae26e6e153069accf7f51d9c3bc4e11428be2a068139f1ecc9656b5e4fc95bb40275f999a35e24e60bae5c0462dcdf03098e40a030
6
+ metadata.gz: '0804f99b21ac1cf54aa5d379957b1a3bec5af4a2aa3630bb2c0210d3b594cb98cf56dc3af4cc3693e1d872b2855ade1f1f2dc97a03760f56d16af85994f56547'
7
+ data.tar.gz: 07626c80512de4d475cd1cd4b7fbe31a210718dbc8a5d2b81a97009900f8cc1df91b79cae903bfc1b0769a049ab224232cccadfd2b86f61c50ce83e5e08766f5
data/CHANGELOG.md CHANGED
@@ -1,3 +1,201 @@
1
+ ## [0.4.0] - 2026-07-06
2
+
3
+ ### Added
4
+
5
+ - **Authority dispatch path** — a hand-rolled JSON-RPC front-end for a first-party
6
+ server that authenticates tokens locally and serves its OWN tools (and, as a
7
+ gateway, aggregates + proxies upstreams) WITHOUT the official `mcp` SDK in the
8
+ request path. It coexists with the SDK-backed satellite path
9
+ (`McpToolkit::Server.build`) by design — the gem now carries two dispatch
10
+ front-ends, each for its role. The satellite path is unchanged.
11
+ - `McpToolkit::Protocol` — JSON-RPC constants + envelope helpers
12
+ (`SUPPORTED_VERSIONS`, `LATEST_VERSION`, `JSONRPC_VERSION`, `ErrorCodes`,
13
+ `Error` + subclasses with `#code`/`#data`/`#to_h`, `success_response` /
14
+ `error_response`). The byte contract of a first-party endpoint's error
15
+ envelope + version negotiation.
16
+ - `McpToolkit::Dispatcher` — `new(context:, config:)` + `#handle_request(request)`.
17
+ Dispatches `initialize` / `initialized` / `tools/list` / `tools/call` / `ping`
18
+ and the custom `notifications/<app>/tools/list_changed` cache-bust. `tools/list`
19
+ merges the host's own tool definitions with the gateway's namespaced upstream
20
+ tools; `tools/call` routes a namespaced name to `Gateway::Proxy` (translating
21
+ `UnknownUpstream` → method-not-found and relaying an upstream JSON-RPC error
22
+ verbatim) or a host tool (scope-gated) otherwise. Server identity + negotiated
23
+ versions come from config; NO SDK touchpoint.
24
+ - `McpToolkit::Authority::ControllerMethods` — the authority transport as an
25
+ includable concern. Every billing/tenancy step is an overridable hook
26
+ (`mcp_authenticate!`, `mcp_rate_limit!`, `mcp_track_usage`, `mcp_flush_usage`,
27
+ `mcp_resolve_account`, `mcp_session_data`, `mcp_dispatch`, `mcp_health_payload`,
28
+ `mcp_config`), each defaulting to a config callable so a PURE host needs no
29
+ subclass. The per-request loop RE-RESOLVES the account for every JSON-RPC call
30
+ — including each element of a batch — so a mixed-account batch still meters one
31
+ usage event per call against the right account (the batch is never delegated to
32
+ a bulk handler that couldn't re-resolve per element).
33
+ - `McpToolkit::Authority::ServerController` — a base controller (concern wired in,
34
+ lazily-parented) a host subclasses when its hooks touch app models (the
35
+ recommended path).
36
+ - `McpToolkit::Authority::Context` — the per-request context threaded into the
37
+ dispatcher + tools: `account`, `principal`, `bearer_token`, and a derived
38
+ `superuser?` (duck-typed off the principal).
39
+ - `McpToolkit::Tools::AuthorityBase` — an optional base for a host's own tools
40
+ (class DSL `tool_name` / `description` / `input_schema` /
41
+ `required_permissions_scope` / `definition`; `.call(context:, **arguments)`
42
+ entry; context accessors; `ensure_resource_accessible!`; ArgumentError →
43
+ InvalidParams / StandardError → InternalError mapping). Host tools plug in
44
+ through the api-agnostic `config.tool_provider` seam — the gem never references
45
+ a host's API layer, serializers, or resource catalog.
46
+ - **`config.tool_provider`** — the api-agnostic tool seam. Duck-typed:
47
+ `provider.tool_definitions(context) -> [{ name:, description:, inputSchema: }]`
48
+ (context lets the host hide superuser-only tools) and `provider.find(name) -> a
49
+ tool object` (responding to `#required_permissions_scope` + `#call(context:,
50
+ **arguments)`). The dispatcher enforces the per-tool scope gate CENTRALLY.
51
+ - **Registry-backed authority tools** — the authority-path counterpart to the
52
+ satellite's SDK tools, so a first-party server can serve the SAME four generic
53
+ read tools (`resources` / `resource_schema` / `get` / `list`) over
54
+ `config.registry` through the hand-rolled dispatcher, reusing the existing
55
+ `ListExecutor` / `GetExecutor` / `ResourceSchema` / `Serialization` /
56
+ `FieldSelection` / `Filtering` UNCHANGED.
57
+ - `McpToolkit::Authority::RegistryToolProvider.new(config:)` — a `tool_provider`
58
+ serving the four generic tools; `find(name)` returns a tool instance, and each
59
+ tool declares NO static scope (the per-resource scope is enforced dynamically
60
+ at call time). The satellite SDK tool path (`McpToolkit::Tools::*`,
61
+ `McpToolkit::Server`) is untouched — this is added alongside it.
62
+ - `McpToolkit::Authority::Tools::{Resources,ResourceSchema,Get,List}` — the four
63
+ thin tools. Each resolves the `resource` argument against the registry
64
+ (InvalidParams for unknown), gates a `superusers_only?` resource against
65
+ `context.superuser?` (REFUSE in get/list/resource_schema, HIDE in resources),
66
+ gates the resource's `required_scope_for` against the principal, and (get/list)
67
+ requires a resolved `context.account`. Returns a raw Hash for the dispatcher to
68
+ wrap — distinct by design from the satellite tools' `MCP::Tool::Response`.
69
+ - `McpToolkit::Authority::CompositeToolProvider.new(*providers)` — composes
70
+ several providers (e.g. the RegistryToolProvider + a host's bespoke tools)
71
+ behind one `config.tool_provider`: `tool_definitions` concatenates in order,
72
+ `find` returns the first match.
73
+ - `config.generic_tool_name_prefix` (default `""`) — namespaces the four generic
74
+ Registry-backed tools. When set (e.g. `"foo_"`) the provider advertises and
75
+ resolves them as `foo_resources` / `foo_resource_schema` / `foo_get` /
76
+ `foo_list`, letting a host keep stable, namespaced tool names for existing
77
+ clients; the empty default keeps the bare base names.
78
+ - **`McpToolkit::Resource` generic seams** (all api-agnostic) — `superusers_only!`
79
+ / `superusers_only?` (authority tools honor it), `note(text)` + reader (surfaced
80
+ by `resource_schema`), and `filter(name, type:, description:, &applier)` +
81
+ `custom_filters` — a resource-specific filter whose block narrows the scoped
82
+ relation from a TOP-LEVEL request param, so a host can express a relational
83
+ filter the generic equality/operator allowlist can't. `ListExecutor` applies the
84
+ matching custom filters BEFORE the allowlist filters (its only change).
85
+ - **`McpToolkit::ResourceSchema` enrichment** — each attribute now advertises the
86
+ filter `operators` it accepts (derived from `Filtering::OPERATORS_BY_TYPE`), and
87
+ the resource `note` is passed through, so a client can discover exactly which
88
+ `{ op:, value: }` conditions `list` will accept.
89
+ - **Server-vs-gateway identity split** — `config.gateway_client_name` /
90
+ `gateway_client_version` (each defaulting to `server_name` / `server_version`).
91
+ `Gateway::Client`'s handshake `clientInfo` now reads the GATEWAY identity, so an
92
+ authority can advertise its own `server_name` to its callers while keeping its
93
+ upstream handshake byte-identical.
94
+ - **`config.supported_protocol_versions`** (default
95
+ `McpToolkit::Protocol::SUPPORTED_VERSIONS`) — the version set the authority
96
+ dispatcher negotiates.
97
+ - **`config.rate_limiter` / `usage_recorder` / `usage_flusher`** — the authority
98
+ transport's billing hooks as config callables (all default `nil` / no-op).
99
+ - **Built-in rate limiting** — `McpToolkit::RateLimiter`, a fixed-window
100
+ per-principal counter backed by `config.cache_store`, plus
101
+ `config.rate_limit_max_requests` (Integer, default `nil` = OFF) and
102
+ `config.rate_limit_window` (seconds, default `3600`). When a cap is set, the
103
+ authority transport's `mcp_rate_limit!` counts each request, sets the
104
+ `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset` headers on
105
+ every capped response, and over the limit renders a JSON-RPC error (code
106
+ `-32029`) at HTTP `429` with a `Retry-After` header. Two new overridable hooks —
107
+ `mcp_rate_limit_max_requests` (default `config.rate_limit_max_requests`) and
108
+ `mcp_rate_limit_key` (default `mcp_principal.id`) — let a host keep the cap in
109
+ its own constant/model or bucket the counter differently. `config.rate_limiter`
110
+ remains as an escape hatch that fully replaces the built-in when set. A pure
111
+ host that sets no cap is unaffected.
112
+ - **`config.superuser_resolver`** — an optional `->(principal) -> Boolean` making
113
+ superuser a first-class, OPTIONAL gem concept. `Authority::Context#superuser?`
114
+ calls it when set, else falls back to duck-typing `principal.superuser?` (false
115
+ when the principal doesn't respond to it). Together with the existing
116
+ `superusers_only!` resource flag and the authority tools' gating, this
117
+ formalizes superuser gating; the default (no resolver, no superuser-aware
118
+ principal) is "no superusers".
119
+ - **Lazy `parent_controller` (Constraint B)** — the engine's `ServerController` /
120
+ `TokensController` and the authority `ServerController` are no longer eager-
121
+ loadable files; they are built from the CURRENT config by
122
+ `McpToolkit.build_engine_controllers!`, triggered lazily via `const_missing` and
123
+ reset on each reload by the engine's `config.to_prepare`. The parent is therefore
124
+ read only at build time — after the host's initializers/to_prepare — so a host's
125
+ whole MCP initializer can live in `to_prepare`. `TokensController#introspect`
126
+ behavior is preserved exactly.
127
+
128
+ ### Fixed
129
+
130
+ - **Authority boundary returns JSON-RPC errors for bad input, not a 500** — a
131
+ malformed JSON body now maps to a JSON-RPC parse error (`-32700`) via a
132
+ `respond_to?`-guarded `rescue_from` (fires even from the session before_action),
133
+ and a non-object request or batch element maps to `invalid_request` instead of
134
+ raising a `NoMethodError` in the per-call loop.
135
+ - **`initialize` advertises `instructions`** — the authority dispatcher now
136
+ includes `config.server_instructions` in the `initialize` result when set
137
+ (omitted when nil), matching the SDK-backed satellite server and the documented
138
+ contract.
139
+ - **Gateway tool-list cache is contract-enforced, not assumption-based** — the
140
+ per-upstream list cache is keyed by upstream only, which is safe only when every
141
+ upstream's `tools/list` is caller-independent. That is now an explicit
142
+ registration contract: an upstream that filters its list by caller privilege
143
+ registers `public_tool_list: false` and is pulled live per request (never
144
+ cached), so a privileged caller's list can't leak to an unprivileged one.
145
+
146
+ ### Removed
147
+
148
+ - The engine's `app/controllers/mcp_toolkit/{server_controller,tokens_controller}.rb`
149
+ files, replaced by the lazy builder above (their routes + behavior are unchanged).
150
+
151
+ - **Gateway / upstream layer** (`McpToolkit::Gateway::*`) — the generic,
152
+ SDK-independent machinery a central app uses to aggregate *other* MCP servers
153
+ and proxy calls to them, previously an app-only concern. All app-specific values
154
+ (upstream URLs, account-selector meta key, logger, timeouts) are injected via
155
+ `McpToolkit::Configuration`; nothing in the layer names a deployment.
156
+ - `McpToolkit::Gateway::UpstreamRegistry` — a PER-CONFIG registry of upstream
157
+ servers (`Upstream = Data.define(:key, :url, :public_tool_list)` with
158
+ `#name_for`), exposed as `config.upstreams` and reset with a fresh config (test
159
+ isolation for free). API: `#register(key:, url:, public_tool_list: true)` (blank
160
+ url ignored), `#reset!`, `#all`, `#find`, `#split_tool_name`. Config sugar:
161
+ `config.register_upstream(key:, url:, public_tool_list: true)`.
162
+ - `McpToolkit::Gateway::Client` — a minimal Streamable-HTTP MCP client
163
+ (`#tools_list`, `#tools_call`) with single-shot session-loss recovery (HTTP
164
+ 404 / JSON-RPC `-32001`), SSE `data:` unwrapping, and content negotiation. Its
165
+ `Client::Error` (< `McpToolkit::Error`) carries `jsonrpc_error` / `http_status`
166
+ and references NO transport/protocol-error class — the consumer maps it. The
167
+ handshake `clientInfo` and protocol version come from config
168
+ (`DEFAULT_PROTOCOL_VERSION` falls back to the wrapped `mcp` SDK's latest).
169
+ - `McpToolkit::Gateway::Aggregator` — namespaces + caches (`config.cache_store`,
170
+ `config.upstream_list_ttl`) each upstream's tool list, pulled CONCURRENTLY via
171
+ concurrent-ruby (wrapped in `Rails.application.executor` when a booted Rails app
172
+ is present, plain futures otherwise). Only a non-empty pull is cached; a stale
173
+ empty is a miss (poisoned-cache self-heal); a failing upstream degrades (omit +
174
+ log) rather than breaking the list.
175
+ - `McpToolkit::Gateway::Proxy` — proxies a namespaced call, forwarding the
176
+ resolved `account_id` as `_meta[config.account_meta_key]`. An unknown key raises
177
+ `McpToolkit::Gateway::UnknownUpstream`; an upstream failure raises
178
+ `McpToolkit::Gateway::UpstreamCallError` (carrying `jsonrpc_error` /
179
+ `http_status`). Neither is mapped to a protocol-error class here.
180
+ - **Authority introspection endpoint** — `McpToolkit::TokensController#introspect`,
181
+ drawn by the engine at `POST /mcp/tokens/introspect`, so a central app answers
182
+ introspection with no controller of its own. Its parent class is configurable via
183
+ `parent_controller` (like `ServerController`). The route is drawn ONLY when
184
+ `auth_role` is `:authority`: introspection is the provider side of the protocol,
185
+ so a satellite (the default role) that mounts the engine gets no such route
186
+ rather than one it should never answer. The controller also fails safe as defence
187
+ in depth — with no `token_authenticator` it answers `{ valid: false }`.
188
+ - **`McpToolkit::Session#data`** — an opaque payload attachable at
189
+ `create!(data:)` and round-tripped through `find`, so an authority can bind a
190
+ session to a token id (letting a revoked token kill an in-flight session). The
191
+ gem does not interpret it; legacy rows default to `{}`.
192
+ - `McpToolkit::Configuration` gains `upstreams` (a `Gateway::UpstreamRegistry`),
193
+ `register_upstream`, `upstream_timeout` (default `10`), `upstream_list_ttl`
194
+ (default `900`), and `logger` (default `nil`; all gateway/session call sites
195
+ guard with `logger&.`).
196
+ - `concurrent-ruby` is now a direct dependency (already transitive via
197
+ activesupport) — the aggregator's parallel upstream pulls require it.
198
+
1
199
  ## [0.3.0] - 2026-07-03
2
200
 
3
201
  ### Added
@@ -119,5 +317,6 @@ hand-rolled controller wiring.
119
317
 
120
318
  ### Notes
121
319
 
122
- - The gateway / upstream-aggregation layer (`Mcp::Upstreams*`) is intentionally
123
- out of scope (core-only).
320
+ - The gateway / upstream-aggregation layer was intentionally out of scope for this
321
+ initial extraction. It was later extracted into the gem in 0.4.0 (see above), as
322
+ `McpToolkit::Gateway::*` — fully config-injected and app-agnostic.