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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +432 -2
- data/README.md +315 -34
- data/config/routes.rb +20 -5
- data/lib/mcp_toolkit/auth/authority.rb +2 -2
- data/lib/mcp_toolkit/authority/composite_tool_provider.rb +32 -0
- data/lib/mcp_toolkit/authority/context.rb +45 -0
- data/lib/mcp_toolkit/authority/controller_methods.rb +423 -0
- data/lib/mcp_toolkit/authority/registry_tool_provider.rb +69 -0
- data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
- data/lib/mcp_toolkit/authority/token.rb +124 -0
- data/lib/mcp_toolkit/authority/tools/base.rb +150 -0
- data/lib/mcp_toolkit/authority/tools/get.rb +59 -0
- data/lib/mcp_toolkit/authority/tools/list.rb +132 -0
- data/lib/mcp_toolkit/authority/tools/resource_schema.rb +52 -0
- data/lib/mcp_toolkit/authority/tools/resources.rb +55 -0
- data/lib/mcp_toolkit/authority.rb +24 -0
- data/lib/mcp_toolkit/configuration.rb +362 -5
- data/lib/mcp_toolkit/dispatcher.rb +248 -0
- data/lib/mcp_toolkit/engine.rb +26 -8
- data/lib/mcp_toolkit/engine_controllers.rb +124 -0
- data/lib/mcp_toolkit/filtering.rb +168 -23
- data/lib/mcp_toolkit/gateway/aggregator.rb +142 -0
- data/lib/mcp_toolkit/gateway/client.rb +305 -0
- data/lib/mcp_toolkit/gateway/proxy.rb +70 -0
- data/lib/mcp_toolkit/gateway/unknown_upstream.rb +8 -0
- data/lib/mcp_toolkit/gateway/upstream_call_error.rb +23 -0
- data/lib/mcp_toolkit/gateway/upstream_registry.rb +79 -0
- data/lib/mcp_toolkit/list_executor.rb +65 -4
- data/lib/mcp_toolkit/protocol.rb +106 -0
- data/lib/mcp_toolkit/rate_limiter.rb +73 -0
- data/lib/mcp_toolkit/registry.rb +30 -2
- data/lib/mcp_toolkit/resource.rb +173 -6
- data/lib/mcp_toolkit/resource_schema.rb +135 -13
- data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
- data/lib/mcp_toolkit/serializer/base.rb +2 -2
- data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
- data/lib/mcp_toolkit/session.rb +18 -10
- data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
- data/lib/mcp_toolkit/tools/authority_base.rb +148 -0
- data/lib/mcp_toolkit/tools/base.rb +23 -3
- data/lib/mcp_toolkit/tools/get.rb +1 -1
- data/lib/mcp_toolkit/tools/list.rb +27 -9
- data/lib/mcp_toolkit/tools/resource_schema.rb +12 -3
- data/lib/mcp_toolkit/tools/resources.rb +30 -7
- data/lib/mcp_toolkit/transport/controller_methods.rb +2 -2
- data/lib/mcp_toolkit/usage_metering/recorder.rb +121 -0
- data/lib/mcp_toolkit/version.rb +1 -1
- data/lib/mcp_toolkit.rb +16 -3
- metadata +42 -2
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d9e89469906c5d75ea46174ecf1e7368ffbe93669ec80526e559f683c778516b
|
|
4
|
+
data.tar.gz: 6c97881a9edbd3df1185069364c99457e90fecb4ecf95a7a6c85612e9f5d0d2e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
123
|
-
|
|
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.
|