mcp_toolkit 0.3.0 → 0.5.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -2
  3. data/README.md +315 -34
  4. data/config/routes.rb +20 -5
  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 +423 -0
  9. data/lib/mcp_toolkit/authority/registry_tool_provider.rb +69 -0
  10. data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
  11. data/lib/mcp_toolkit/authority/token.rb +124 -0
  12. data/lib/mcp_toolkit/authority/tools/base.rb +150 -0
  13. data/lib/mcp_toolkit/authority/tools/get.rb +59 -0
  14. data/lib/mcp_toolkit/authority/tools/list.rb +132 -0
  15. data/lib/mcp_toolkit/authority/tools/resource_schema.rb +52 -0
  16. data/lib/mcp_toolkit/authority/tools/resources.rb +55 -0
  17. data/lib/mcp_toolkit/authority.rb +24 -0
  18. data/lib/mcp_toolkit/configuration.rb +362 -5
  19. data/lib/mcp_toolkit/dispatcher.rb +248 -0
  20. data/lib/mcp_toolkit/engine.rb +26 -8
  21. data/lib/mcp_toolkit/engine_controllers.rb +124 -0
  22. data/lib/mcp_toolkit/filtering.rb +168 -23
  23. data/lib/mcp_toolkit/gateway/aggregator.rb +142 -0
  24. data/lib/mcp_toolkit/gateway/client.rb +305 -0
  25. data/lib/mcp_toolkit/gateway/proxy.rb +70 -0
  26. data/lib/mcp_toolkit/gateway/unknown_upstream.rb +8 -0
  27. data/lib/mcp_toolkit/gateway/upstream_call_error.rb +23 -0
  28. data/lib/mcp_toolkit/gateway/upstream_registry.rb +79 -0
  29. data/lib/mcp_toolkit/list_executor.rb +65 -4
  30. data/lib/mcp_toolkit/protocol.rb +106 -0
  31. data/lib/mcp_toolkit/rate_limiter.rb +73 -0
  32. data/lib/mcp_toolkit/registry.rb +30 -2
  33. data/lib/mcp_toolkit/resource.rb +173 -6
  34. data/lib/mcp_toolkit/resource_schema.rb +135 -13
  35. data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
  36. data/lib/mcp_toolkit/serializer/base.rb +2 -2
  37. data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
  38. data/lib/mcp_toolkit/session.rb +18 -10
  39. data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
  40. data/lib/mcp_toolkit/tools/authority_base.rb +148 -0
  41. data/lib/mcp_toolkit/tools/base.rb +23 -3
  42. data/lib/mcp_toolkit/tools/get.rb +1 -1
  43. data/lib/mcp_toolkit/tools/list.rb +27 -9
  44. data/lib/mcp_toolkit/tools/resource_schema.rb +12 -3
  45. data/lib/mcp_toolkit/tools/resources.rb +30 -7
  46. data/lib/mcp_toolkit/transport/controller_methods.rb +2 -2
  47. data/lib/mcp_toolkit/usage_metering/recorder.rb +121 -0
  48. data/lib/mcp_toolkit/version.rb +1 -1
  49. data/lib/mcp_toolkit.rb +16 -3
  50. metadata +42 -2
  51. 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: d9e89469906c5d75ea46174ecf1e7368ffbe93669ec80526e559f683c778516b
4
+ data.tar.gz: 6c97881a9edbd3df1185069364c99457e90fecb4ecf95a7a6c85612e9f5d0d2e
5
5
  SHA512:
6
- metadata.gz: 50cc58bd25162c2ecf70b97b90b1de329a8b3858494dc719699d8cf609607d3c6f8abacef5c1ed1f0e0eea8b7dc9760c0a4752a816433e65ee661147404a53d8
7
- data.tar.gz: 72474820f84e31bf3601d5ae26e6e153069accf7f51d9c3bc4e11428be2a068139f1ecc9656b5e4fc95bb40275f999a35e24e60bae5c0462dcdf03098e40a030
6
+ metadata.gz: 21daf10ed0e045d978a698482d87581222994cb3ba7aba15f6252f236b5d102bb71a27c29e6d0f05f2e762889fd3dbc8fb58fb3ca5c8293870b46d81145bf540
7
+ data.tar.gz: 96ddb6e0fb9c72353b17d725a171b6c065537cabb55b8d9f3d208d6bad71bf76291c5394678dedba38e742474d6351234bef6d56240fff0dd9ed5649ce465196
data/CHANGELOG.md CHANGED
@@ -1,3 +1,432 @@
1
+ ## [0.5.0] - 2026-07-14
2
+
3
+ Authority-path discoverability + backward-compatibility work (driven by an
4
+ adopting host's parity review against the API contract the gem replaced), plus a
5
+ role-aware mountable engine so an authority mounts its transport in one line, and
6
+ filter-path hardening from a security review.
7
+
8
+ ### Security
9
+
10
+ - `config.filter_operator_overrides` now rejects, at assignment time, any
11
+ operator outside `Filtering::AREL_PREDICATIONS`. Those are the only operators
12
+ the gem maps onto an Arel predication that binds/quotes its value; a host that
13
+ configured anything else (e.g. `"extract"`) would have it `public_send` to an
14
+ Arel attribute with the request value passed through verbatim — an
15
+ SQL-injection surface. A defense-in-depth guard in `Filtering.predicate_for`
16
+ also refuses to dispatch any non-predication operator, so the metaprogramming
17
+ call can never be reached with an unvetted method name. The default and
18
+ intended (`{ text: %w[eq in] }`-style) configurations were never vulnerable.
19
+ - New `config.max_filter_values` (default `500`, `nil` disables) caps how many
20
+ values an IN-set filter may resolve to and how many operator conditions may be
21
+ ANDed on one attribute, so a valid token can't emit an unbounded IN clause /
22
+ AND-chain (oversized SQL + Arel AST + expensive planning). Rate limiting
23
+ remains opt-in via `config.rate_limit_max_requests`.
24
+ - Added an injection-safety regression spec that renders real Arel SQL through a
25
+ correctly-escaping connection and asserts hostile payloads stay inside escaped
26
+ string literals (the prior fake connection did not escape quotes, so it could
27
+ not have caught an escaping regression).
28
+ - New `config.max_batch_size` (default `50`, `nil` disables) caps the number of
29
+ JSON-RPC calls a single authority POST batch may carry. Rate limiting is a
30
+ per-HTTP-request `before_action`, so an uncapped batch let one request fan out
31
+ unbounded work (N tool executions / N blocking upstream calls) under a single
32
+ rate-limit tick; an over-size batch is now rejected as a JSON-RPC error before
33
+ any element runs.
34
+ - The top-level `list` `ids` filter now honors `config.max_filter_values` — it
35
+ built `WHERE id IN (...)` on its own path, bypassing the cap that already
36
+ bounds the per-attribute filters.
37
+ - The authority dispatcher no longer relays an unexpected exception's message to
38
+ the caller: an unhandled `StandardError` returns a generic "Internal error"
39
+ (full detail still logged), so `ActiveRecord::StatementInvalid` SQL, internal
40
+ class names, or an internal hostname can't leak in the JSON-RPC error.
41
+ - The gateway `tools/list` aggregator now degrades a single malformed upstream
42
+ tool entry (a non-Hash / name-less definition) by skipping it, and wraps each
43
+ upstream's processing so any unexpected error omits only that upstream instead
44
+ of 500-ing the whole aggregated list for every upstream.
45
+ - Usage metering flush falls back to per-event writes when the batch write
46
+ fails, so one un-persistable ("poison") event can no longer drop metering for
47
+ a whole request's batch (a billing-evasion vector).
48
+ - The satellite tool path (`get` / `list` / `resource_schema` / `resources`) now
49
+ enforces `Resource#superusers_only!` — previously only the authority path did,
50
+ so a superuser-only resource served via a satellite was readable/discoverable
51
+ by any valid token (still account-scoped, so not cross-tenant). `get`/`list`/
52
+ `resource_schema` refuse it for a non-superuser; `resources` hides it.
53
+ - The authority dispatcher now strips a caller-supplied `context` from a tool's
54
+ arguments before the keyword splat. `tool.call(context:, **arguments)` let a
55
+ splatted `context` argument OVERRIDE the gem-resolved `Authority::Context`
56
+ (auth-context injection) — harmless for the gem's own tools (a JSON context
57
+ fails closed with a NoMethodError) but the gem handed attacker-controlled data
58
+ as `context` to arbitrary host tools.
59
+ - The gateway's transport-failure relay no longer leaks the internal upstream
60
+ host:port. `translate_upstream_call_error` returned `InternalError.new(error.message)`
61
+ for a transport failure, whose message is `"Failed to open TCP connection to
62
+ <host>:<port>"`; it now returns a generic error (the proxy already logs the
63
+ detail). A first-party upstream JSON-RPC error is still relayed verbatim.
64
+ - `Tools::AuthorityBase#execute` no longer relays an unexpected exception's
65
+ message to the caller — it returns a generic "Internal error" (detail logged),
66
+ matching the dispatcher's own catch-all.
67
+ - Usage metering's per-event flush fallback now cannot escape into the response:
68
+ a misbehaving `logger`/`error_reporter` in `flush_individually` is swallowed as
69
+ a last resort, preserving the "metering never affects the MCP response"
70
+ invariant.
71
+
72
+ ### Added
73
+
74
+ - The mountable `McpToolkit::Engine` is now ROLE-AWARE: the
75
+ `McpToolkit::ServerController` it mounts at POST/GET/DELETE /mcp is built from
76
+ `config.auth_role` — an authority host gets the hand-rolled dispatcher path
77
+ (local token auth, gateway proxying, usage metering, rate limiting), a
78
+ satellite gets the SDK-backed path. So an authority now mounts its whole
79
+ transport with `mount McpToolkit::Engine => "/mcp"` (identical to a satellite)
80
+ instead of hand-drawing the four routes against a subclass of
81
+ `McpToolkit::Authority::ServerController` — which is still supported for a host
82
+ that prefers to draw its own routes.
83
+ - `resource_schema` surfaces a resource's custom filters (`Resource#filter`)
84
+ under `resource_filters` — name, type and description — so a client can
85
+ discover them. The `Resource#filter` docs always promised this; nothing
86
+ delivered it, leaving custom filters functional but unadvertised.
87
+ - The `resources` tool returns `filterable` (whether the resource accepts any
88
+ filter — allowlist or custom) and the resource's usage `note` alongside
89
+ name/description, so caveats surface at browse time, before a client picks a
90
+ resource.
91
+ - The `list` tool description documents the full filter grammar — bare
92
+ equality, comma/array IN sets, the `"null"` token, `{ op:, value: }`
93
+ conditions and AND-ed condition arrays — plus resource-specific top-level
94
+ filters. Previously the operator payload shape was not documented anywhere a
95
+ client could see at runtime.
96
+ - Bare equality filters accept an Array of scalars as an IN set
97
+ (`filter: { status: ["a", "b"] }`). Previously the array was stringified and
98
+ comma-split into fragments that silently matched nothing.
99
+ - A JSON null filter value filters for `IS NULL` (like the `"null"` string
100
+ token). Previously it was silently ignored.
101
+
102
+ All of the above applies to the SATELLITE generic tools too: they share the
103
+ executors and schema builder, so their `resources` output gains
104
+ `filterable`/`note`, their `resource_schema` output gains `resource_filters`,
105
+ and their descriptions document the same filter grammar.
106
+
107
+ ### Host-compatibility seams (full parity with a pre-gem API contract)
108
+
109
+ For a host migrating an EXISTING MCP endpoint onto the gem, whose clients hold
110
+ the pre-gem contract:
111
+
112
+ - `config.bare_filter_value_semantics = :literal` — bare filter values reach
113
+ the WHERE clause verbatim (`"a,b"` is one literal string, `"null"` is the
114
+ literal string, `""` matches empty-string rows, an Array — including nil
115
+ elements — gets the adapter's native IN / OR-IS-NULL handling). The default
116
+ `:tokenized` keeps the gem's comma/IN/`"null"`-token grammar. Operator
117
+ conditions are identical in both modes.
118
+ - `config.non_numeric_pk_order = :primary_key` — lists of non-numeric-PK
119
+ resources order by the primary key alone, preserving a pre-gem
120
+ ORDER BY id contract. The default `:created_at` keeps chronological pages
121
+ with the PK tiebreaker.
122
+ - `Resource#filter_requirements` — declares companion-key requirements (e.g. a
123
+ polymorphic foreign key that is type-ambiguous without its `*_type`): the
124
+ list executor rejects the key without its companion ("filter attribute X
125
+ requires Y to also be provided") and `resource_schema` advertises the
126
+ requirement, restoring safe polymorphic-FK filtering instead of dropping the
127
+ key from the allowlist. Accepts a Hash or a lazily-resolved callable, like
128
+ `filterable`.
129
+ - `resource_schema` output restores the remaining pre-gem keys: top-level
130
+ `sparse_fieldsets: true` and `filter_examples` (ready-to-use payloads built
131
+ from the resource's own attributes/relationships), `relationships[].resource`
132
+ (nullable; `target_resource` remains as the resolved alias) and
133
+ `relationships[].filter` (`keys` / `type` / `operators` / `requires`).
134
+ Top-level nil keys are compacted (a nil `note` is omitted again).
135
+ - Operator conditions work on ANY column type: types outside the operator
136
+ table (uuid, enum, jsonb, ...) accept `eq` / `in` instead of failing with
137
+ "cannot be filtered with operators", and `date` columns accept `in` again.
138
+ - The `list` tools' input schemas declare `additionalProperties: true`
139
+ explicitly (resource-specific filters arrive as top-level arguments).
140
+ - `config.register_upstreams_from_env(mapping, env: ENV)` — declares gateway
141
+ upstreams from a `{ key => env var }` map: resets the registry first
142
+ (idempotent across code reloads) and skips blank urls, the two gotchas every
143
+ authority host re-discovers.
144
+ - `config.tool_provider` composes a sensible default when unset: the generic
145
+ Registry-backed provider (only when resources are registered — a pure
146
+ gateway still contributes nothing) plus `config.extra_tool_providers`
147
+ (providers, or bare tool classes auto-wrapped in the new
148
+ `Authority::SingleToolProvider`). Hosts with one bespoke tool no longer
149
+ hand-roll provider plumbing; assigning `tool_provider` explicitly still
150
+ takes full control.
151
+ - `McpToolkit::Serializer::AssociationDescriptor` + `TargetRef` — the exported
152
+ structs for the association duck-type the schema builder and field selection
153
+ probe, so a host adapting its own serializer framework doesn't re-derive the
154
+ field names by hand.
155
+ - The authority `list` tool's served description states the bare-value grammar
156
+ the host ACTUALLY configured: under `:literal` semantics the comma/`"null"`
157
+ tokenization bullet is replaced by the literal-matching one, so served docs
158
+ never advertise filters that would silently match nothing.
159
+ - The authority tools are advertised in alphabetical base-name order
160
+ (`get`, `list`, `resource_schema`, `resources`) and string/text operator
161
+ lists keep the pre-gem order (`eq, in, not_eq, matches, does_not_match`) —
162
+ JSON arrays are ordered, so byte-diffing clients see no reorder.
163
+ - `get` / `resource_schema` reject arguments outside their input schema with
164
+ InvalidParams instead of silently ignoring them (pre-gem parity — they were
165
+ strict Ruby kwargs; `account_id` is always tolerated, the transport consumes
166
+ it). `resources` and `list` stay tolerant of extra arguments, also matching
167
+ the pre-gem contract (`list`'s extras are the resource-specific filters).
168
+ - `config.filter_operator_overrides` — per-column-type overrides for the
169
+ operator sets advertised by `resource_schema` AND enforced by the executor
170
+ (single source, they cannot disagree), so a host can preserve a pre-gem
171
+ operator contract exactly (e.g. `{ text: %w[eq in], date: %w[eq in] }`).
172
+ Empty by default: the gem's own sets apply.
173
+ - A companion key whose value the executor would SKIP (an empty string under
174
+ `:tokenized` semantics) no longer satisfies a `filter_requirements` pairing —
175
+ the foreign key is rejected rather than applied alone (type-ambiguous).
176
+ - `resource_filters` entries keep nil `type`/`description` keys (pre-gem
177
+ shape) instead of compacting them; the relationship `filter_examples`
178
+ companion sample value is `"User"` (pre-gem sample) rather than `"..."`.
179
+
180
+ ### Known operator-path delta (documented, not reverted)
181
+
182
+ - `{ op: "in", value: "a,b" }` now splits the comma-separated string into an
183
+ IN set (previously `in` matched the literal string `'a,b'` as a single
184
+ element; only `eq` split). Comma-separated STRING ELEMENTS inside an Array
185
+ value are split the same way — under the tokenized operator grammar there is
186
+ no way to express a literal comma inside an IN element; a literal
187
+ comma-containing match is expressed as a bare equality value (which hosts on
188
+ `:literal` semantics match verbatim).
189
+
190
+ ### Fixed
191
+
192
+ - Generic tool descriptions and input schemas rewrite sibling-tool references
193
+ (e.g. "use the `resources` tool") to carry `config.generic_tool_name_prefix`,
194
+ so a host that namespaces its generic tools no longer serves prose pointing
195
+ at unprefixed tool names that do not exist on its server. The gateway
196
+ aggregator applies the same rewrite with the upstream namespace, so a proxied
197
+ `<app>__list` no longer points a client at the upstream's bare tool names
198
+ (`McpToolkit::ToolReferenceRewriter`).
199
+ - `eq` / `in` operator conditions against `"null"` / null render `IS NULL`
200
+ instead of `IN (NULL)`, which matches no rows in SQL.
201
+ - `eq` / `in` operator conditions accept an Array `value` (previously
202
+ stringified and comma-split into fragments).
203
+ - Non-numeric-PK resources order by `created_at` WITH the primary key as a
204
+ tiebreaker, restoring a total order so offset pagination cannot duplicate or
205
+ skip rows that share a timestamp (e.g. bulk inserts).
206
+ - An Array mixing `{ op:, value: }` conditions with bare values is rejected
207
+ with InvalidParams instead of being misread as bare equality values.
208
+ - One resource's failing lazy `filterable` resolution (e.g. a transient DB
209
+ error inside a host-supplied callable) no longer fails the whole `resources`
210
+ discovery index: the `filterable` key is omitted for that resource and the
211
+ unresolved source is retried on the next read instead of permanently and
212
+ silently resolving the allowlist to `{}`.
213
+
214
+ ### Changed (explicit over silent — each previously returned a wrong or empty result)
215
+
216
+ - IN-set elements must be non-null scalars: a nil, Hash or nested-Array element
217
+ inside an Array filter value raises InvalidParams (previously a Hash element
218
+ raised a TypeError at query time and a nil element rendered the
219
+ never-matching `IN (..., NULL)`). The `"null"` token is NOT resolved inside a
220
+ set — SQL `IN` cannot match NULL — so a null-or-nothing condition is
221
+ expressed as the filter's single scalar value.
222
+ - A null value with an operator other than `eq` / `in` / `not_eq` (comparisons,
223
+ `matches` / `does_not_match`) raises InvalidParams; a comparison or LIKE
224
+ against NULL can never match a row (previously `matches` with a JSON null
225
+ matched every row via `LIKE '%%'`, and comparisons silently matched nothing).
226
+ - An op-less Hash as a bare filter value raises InvalidParams (previously it
227
+ reached the database as a malformed condition).
228
+ - `{ op: "eq", value: "" }` matches rows whose value IS the empty string
229
+ (previously it matched nothing via an empty IN set). A bare `""` filter value
230
+ still means "no filter".
231
+
232
+ ## [0.4.0] - 2026-07-06
233
+
234
+ ### Added
235
+
236
+ - **Authority dispatch path** — a hand-rolled JSON-RPC front-end for a first-party
237
+ server that authenticates tokens locally and serves its OWN tools (and, as a
238
+ gateway, aggregates + proxies upstreams) WITHOUT the official `mcp` SDK in the
239
+ request path. It coexists with the SDK-backed satellite path
240
+ (`McpToolkit::Server.build`) by design — the gem now carries two dispatch
241
+ front-ends, each for its role. The satellite path is unchanged.
242
+ - `McpToolkit::Protocol` — JSON-RPC constants + envelope helpers
243
+ (`SUPPORTED_VERSIONS`, `LATEST_VERSION`, `JSONRPC_VERSION`, `ErrorCodes`,
244
+ `Error` + subclasses with `#code`/`#data`/`#to_h`, `success_response` /
245
+ `error_response`). The byte contract of a first-party endpoint's error
246
+ envelope + version negotiation.
247
+ - `McpToolkit::Dispatcher` — `new(context:, config:)` + `#handle_request(request)`.
248
+ Dispatches `initialize` / `initialized` / `tools/list` / `tools/call` / `ping`
249
+ and the custom `notifications/<app>/tools/list_changed` cache-bust. `tools/list`
250
+ merges the host's own tool definitions with the gateway's namespaced upstream
251
+ tools; `tools/call` routes a namespaced name to `Gateway::Proxy` (translating
252
+ `UnknownUpstream` → method-not-found and relaying an upstream JSON-RPC error
253
+ verbatim) or a host tool (scope-gated) otherwise. Server identity + negotiated
254
+ versions come from config; NO SDK touchpoint.
255
+ - `McpToolkit::Authority::ControllerMethods` — the authority transport as an
256
+ includable concern. Every billing/tenancy step is an overridable hook
257
+ (`mcp_authenticate!`, `mcp_rate_limit!`, `mcp_track_usage`, `mcp_flush_usage`,
258
+ `mcp_resolve_account`, `mcp_session_data`, `mcp_dispatch`, `mcp_health_payload`,
259
+ `mcp_config`), each defaulting to a config callable so a PURE host needs no
260
+ subclass. The per-request loop RE-RESOLVES the account for every JSON-RPC call
261
+ — including each element of a batch — so a mixed-account batch still meters one
262
+ usage event per call against the right account (the batch is never delegated to
263
+ a bulk handler that couldn't re-resolve per element).
264
+ - `McpToolkit::Authority::ServerController` — a base controller (concern wired in,
265
+ lazily-parented) a host subclasses when its hooks touch app models (the
266
+ recommended path).
267
+ - `McpToolkit::Authority::Context` — the per-request context threaded into the
268
+ dispatcher + tools: `account`, `principal`, `bearer_token`, and a derived
269
+ `superuser?` (duck-typed off the principal).
270
+ - `McpToolkit::Tools::AuthorityBase` — an optional base for a host's own tools
271
+ (class DSL `tool_name` / `description` / `input_schema` /
272
+ `required_permissions_scope` / `definition`; `.call(context:, **arguments)`
273
+ entry; context accessors; `ensure_resource_accessible!`; ArgumentError →
274
+ InvalidParams / StandardError → InternalError mapping). Host tools plug in
275
+ through the api-agnostic `config.tool_provider` seam — the gem never references
276
+ a host's API layer, serializers, or resource catalog.
277
+ - **`config.tool_provider`** — the api-agnostic tool seam. Duck-typed:
278
+ `provider.tool_definitions(context) -> [{ name:, description:, inputSchema: }]`
279
+ (context lets the host hide superuser-only tools) and `provider.find(name) -> a
280
+ tool object` (responding to `#required_permissions_scope` + `#call(context:,
281
+ **arguments)`). The dispatcher enforces the per-tool scope gate CENTRALLY.
282
+ - **Registry-backed authority tools** — the authority-path counterpart to the
283
+ satellite's SDK tools, so a first-party server can serve the SAME four generic
284
+ read tools (`resources` / `resource_schema` / `get` / `list`) over
285
+ `config.registry` through the hand-rolled dispatcher, reusing the existing
286
+ `ListExecutor` / `GetExecutor` / `ResourceSchema` / `Serialization` /
287
+ `FieldSelection` / `Filtering` UNCHANGED.
288
+ - `McpToolkit::Authority::RegistryToolProvider.new(config:)` — a `tool_provider`
289
+ serving the four generic tools; `find(name)` returns a tool instance, and each
290
+ tool declares NO static scope (the per-resource scope is enforced dynamically
291
+ at call time). The satellite SDK tool path (`McpToolkit::Tools::*`,
292
+ `McpToolkit::Server`) is untouched — this is added alongside it.
293
+ - `McpToolkit::Authority::Tools::{Resources,ResourceSchema,Get,List}` — the four
294
+ thin tools. Each resolves the `resource` argument against the registry
295
+ (InvalidParams for unknown), gates a `superusers_only?` resource against
296
+ `context.superuser?` (REFUSE in get/list/resource_schema, HIDE in resources),
297
+ gates the resource's `required_scope_for` against the principal, and (get/list)
298
+ requires a resolved `context.account`. Returns a raw Hash for the dispatcher to
299
+ wrap — distinct by design from the satellite tools' `MCP::Tool::Response`.
300
+ - `McpToolkit::Authority::CompositeToolProvider.new(*providers)` — composes
301
+ several providers (e.g. the RegistryToolProvider + a host's bespoke tools)
302
+ behind one `config.tool_provider`: `tool_definitions` concatenates in order,
303
+ `find` returns the first match.
304
+ - `config.generic_tool_name_prefix` (default `""`) — namespaces the four generic
305
+ Registry-backed tools. When set (e.g. `"foo_"`) the provider advertises and
306
+ resolves them as `foo_resources` / `foo_resource_schema` / `foo_get` /
307
+ `foo_list`, letting a host keep stable, namespaced tool names for existing
308
+ clients; the empty default keeps the bare base names.
309
+ - **`McpToolkit::Resource` generic seams** (all api-agnostic) — `superusers_only!`
310
+ / `superusers_only?` (authority tools honor it), `note(text)` + reader (surfaced
311
+ by `resource_schema`), and `filter(name, type:, description:, &applier)` +
312
+ `custom_filters` — a resource-specific filter whose block narrows the scoped
313
+ relation from a TOP-LEVEL request param, so a host can express a relational
314
+ filter the generic equality/operator allowlist can't. `ListExecutor` applies the
315
+ matching custom filters BEFORE the allowlist filters (its only change).
316
+ - **`McpToolkit::ResourceSchema` enrichment** — each attribute now advertises the
317
+ filter `operators` it accepts (derived from `Filtering::OPERATORS_BY_TYPE`), and
318
+ the resource `note` is passed through, so a client can discover exactly which
319
+ `{ op:, value: }` conditions `list` will accept.
320
+ - **Server-vs-gateway identity split** — `config.gateway_client_name` /
321
+ `gateway_client_version` (each defaulting to `server_name` / `server_version`).
322
+ `Gateway::Client`'s handshake `clientInfo` now reads the GATEWAY identity, so an
323
+ authority can advertise its own `server_name` to its callers while keeping its
324
+ upstream handshake byte-identical.
325
+ - **`config.supported_protocol_versions`** (default
326
+ `McpToolkit::Protocol::SUPPORTED_VERSIONS`) — the version set the authority
327
+ dispatcher negotiates.
328
+ - **`config.rate_limiter` / `usage_recorder` / `usage_flusher`** — the authority
329
+ transport's billing hooks as config callables (all default `nil` / no-op).
330
+ - **Built-in rate limiting** — `McpToolkit::RateLimiter`, a fixed-window
331
+ per-principal counter backed by `config.cache_store`, plus
332
+ `config.rate_limit_max_requests` (Integer, default `nil` = OFF) and
333
+ `config.rate_limit_window` (seconds, default `3600`). When a cap is set, the
334
+ authority transport's `mcp_rate_limit!` counts each request, sets the
335
+ `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset` headers on
336
+ every capped response, and over the limit renders a JSON-RPC error (code
337
+ `-32029`) at HTTP `429` with a `Retry-After` header. Two new overridable hooks —
338
+ `mcp_rate_limit_max_requests` (default `config.rate_limit_max_requests`) and
339
+ `mcp_rate_limit_key` (default `mcp_principal.id`) — let a host keep the cap in
340
+ its own constant/model or bucket the counter differently. `config.rate_limiter`
341
+ remains as an escape hatch that fully replaces the built-in when set. A pure
342
+ host that sets no cap is unaffected.
343
+ - **`config.superuser_resolver`** — an optional `->(principal) -> Boolean` making
344
+ superuser a first-class, OPTIONAL gem concept. `Authority::Context#superuser?`
345
+ calls it when set, else falls back to duck-typing `principal.superuser?` (false
346
+ when the principal doesn't respond to it). Together with the existing
347
+ `superusers_only!` resource flag and the authority tools' gating, this
348
+ formalizes superuser gating; the default (no resolver, no superuser-aware
349
+ principal) is "no superusers".
350
+ - **Lazy `parent_controller` (Constraint B)** — the engine's `ServerController` /
351
+ `TokensController` and the authority `ServerController` are no longer eager-
352
+ loadable files; they are built from the CURRENT config by
353
+ `McpToolkit.build_engine_controllers!`, triggered lazily via `const_missing` and
354
+ reset on each reload by the engine's `config.to_prepare`. The parent is therefore
355
+ read only at build time — after the host's initializers/to_prepare — so a host's
356
+ whole MCP initializer can live in `to_prepare`. `TokensController#introspect`
357
+ behavior is preserved exactly.
358
+
359
+ ### Fixed
360
+
361
+ - **Authority boundary returns JSON-RPC errors for bad input, not a 500** — a
362
+ malformed JSON body now maps to a JSON-RPC parse error (`-32700`) via a
363
+ `respond_to?`-guarded `rescue_from` (fires even from the session before_action),
364
+ and a non-object request or batch element maps to `invalid_request` instead of
365
+ raising a `NoMethodError` in the per-call loop.
366
+ - **`initialize` advertises `instructions`** — the authority dispatcher now
367
+ includes `config.server_instructions` in the `initialize` result when set
368
+ (omitted when nil), matching the SDK-backed satellite server and the documented
369
+ contract.
370
+ - **Gateway tool-list cache is contract-enforced, not assumption-based** — the
371
+ per-upstream list cache is keyed by upstream only, which is safe only when every
372
+ upstream's `tools/list` is caller-independent. That is now an explicit
373
+ registration contract: an upstream that filters its list by caller privilege
374
+ registers `public_tool_list: false` and is pulled live per request (never
375
+ cached), so a privileged caller's list can't leak to an unprivileged one.
376
+
377
+ ### Removed
378
+
379
+ - The engine's `app/controllers/mcp_toolkit/{server_controller,tokens_controller}.rb`
380
+ files, replaced by the lazy builder above (their routes + behavior are unchanged).
381
+
382
+ - **Gateway / upstream layer** (`McpToolkit::Gateway::*`) — the generic,
383
+ SDK-independent machinery a central app uses to aggregate *other* MCP servers
384
+ and proxy calls to them, previously an app-only concern. All app-specific values
385
+ (upstream URLs, account-selector meta key, logger, timeouts) are injected via
386
+ `McpToolkit::Configuration`; nothing in the layer names a deployment.
387
+ - `McpToolkit::Gateway::UpstreamRegistry` — a PER-CONFIG registry of upstream
388
+ servers (`Upstream = Data.define(:key, :url, :public_tool_list)` with
389
+ `#name_for`), exposed as `config.upstreams` and reset with a fresh config (test
390
+ isolation for free). API: `#register(key:, url:, public_tool_list: true)` (blank
391
+ url ignored), `#reset!`, `#all`, `#find`, `#split_tool_name`. Config sugar:
392
+ `config.register_upstream(key:, url:, public_tool_list: true)`.
393
+ - `McpToolkit::Gateway::Client` — a minimal Streamable-HTTP MCP client
394
+ (`#tools_list`, `#tools_call`) with single-shot session-loss recovery (HTTP
395
+ 404 / JSON-RPC `-32001`), SSE `data:` unwrapping, and content negotiation. Its
396
+ `Client::Error` (< `McpToolkit::Error`) carries `jsonrpc_error` / `http_status`
397
+ and references NO transport/protocol-error class — the consumer maps it. The
398
+ handshake `clientInfo` and protocol version come from config
399
+ (`DEFAULT_PROTOCOL_VERSION` falls back to the wrapped `mcp` SDK's latest).
400
+ - `McpToolkit::Gateway::Aggregator` — namespaces + caches (`config.cache_store`,
401
+ `config.upstream_list_ttl`) each upstream's tool list, pulled CONCURRENTLY via
402
+ concurrent-ruby (wrapped in `Rails.application.executor` when a booted Rails app
403
+ is present, plain futures otherwise). Only a non-empty pull is cached; a stale
404
+ empty is a miss (poisoned-cache self-heal); a failing upstream degrades (omit +
405
+ log) rather than breaking the list.
406
+ - `McpToolkit::Gateway::Proxy` — proxies a namespaced call, forwarding the
407
+ resolved `account_id` as `_meta[config.account_meta_key]`. An unknown key raises
408
+ `McpToolkit::Gateway::UnknownUpstream`; an upstream failure raises
409
+ `McpToolkit::Gateway::UpstreamCallError` (carrying `jsonrpc_error` /
410
+ `http_status`). Neither is mapped to a protocol-error class here.
411
+ - **Authority introspection endpoint** — `McpToolkit::TokensController#introspect`,
412
+ drawn by the engine at `POST /mcp/tokens/introspect`, so a central app answers
413
+ introspection with no controller of its own. Its parent class is configurable via
414
+ `parent_controller` (like `ServerController`). The route is drawn ONLY when
415
+ `auth_role` is `:authority`: introspection is the provider side of the protocol,
416
+ so a satellite (the default role) that mounts the engine gets no such route
417
+ rather than one it should never answer. The controller also fails safe as defence
418
+ in depth — with no `token_authenticator` it answers `{ valid: false }`.
419
+ - **`McpToolkit::Session#data`** — an opaque payload attachable at
420
+ `create!(data:)` and round-tripped through `find`, so an authority can bind a
421
+ session to a token id (letting a revoked token kill an in-flight session). The
422
+ gem does not interpret it; legacy rows default to `{}`.
423
+ - `McpToolkit::Configuration` gains `upstreams` (a `Gateway::UpstreamRegistry`),
424
+ `register_upstream`, `upstream_timeout` (default `10`), `upstream_list_ttl`
425
+ (default `900`), and `logger` (default `nil`; all gateway/session call sites
426
+ guard with `logger&.`).
427
+ - `concurrent-ruby` is now a direct dependency (already transitive via
428
+ activesupport) — the aggregator's parallel upstream pulls require it.
429
+
1
430
  ## [0.3.0] - 2026-07-03
2
431
 
3
432
  ### Added
@@ -119,5 +548,6 @@ hand-rolled controller wiring.
119
548
 
120
549
  ### Notes
121
550
 
122
- - The gateway / upstream-aggregation layer (`Mcp::Upstreams*`) is intentionally
123
- out of scope (core-only).
551
+ - The gateway / upstream-aggregation layer was intentionally out of scope for this
552
+ initial extraction. It was later extracted into the gem in 0.4.0 (see above), as
553
+ `McpToolkit::Gateway::*` — fully config-injected and app-agnostic.