mcp_toolkit 0.4.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 +231 -0
- data/config/routes.rb +1 -1
- data/lib/mcp_toolkit/authority/controller_methods.rb +15 -0
- data/lib/mcp_toolkit/authority/registry_tool_provider.rb +9 -10
- data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
- data/lib/mcp_toolkit/authority/tools/base.rb +31 -3
- data/lib/mcp_toolkit/authority/tools/get.rb +2 -1
- data/lib/mcp_toolkit/authority/tools/list.rb +49 -1
- data/lib/mcp_toolkit/authority/tools/resource_schema.rb +10 -2
- data/lib/mcp_toolkit/authority/tools/resources.rb +26 -4
- data/lib/mcp_toolkit/configuration.rb +161 -7
- data/lib/mcp_toolkit/dispatcher.rb +17 -3
- data/lib/mcp_toolkit/engine.rb +5 -2
- data/lib/mcp_toolkit/engine_controllers.rb +11 -3
- data/lib/mcp_toolkit/filtering.rb +168 -23
- data/lib/mcp_toolkit/gateway/aggregator.rb +25 -5
- data/lib/mcp_toolkit/list_executor.rb +48 -4
- data/lib/mcp_toolkit/resource.rb +55 -6
- data/lib/mcp_toolkit/resource_schema.rb +125 -16
- data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
- data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
- data/lib/mcp_toolkit/session.rb +2 -2
- data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
- data/lib/mcp_toolkit/tools/authority_base.rb +23 -2
- 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/usage_metering/recorder.rb +28 -2
- data/lib/mcp_toolkit/version.rb +1 -1
- metadata +5 -1
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,234 @@
|
|
|
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
|
+
|
|
1
232
|
## [0.4.0] - 2026-07-06
|
|
2
233
|
|
|
3
234
|
### Added
|
data/config/routes.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Engine routes, drawn through Rails' routes_reloader so they survive route
|
|
4
4
|
# reloads (a class-body `routes.draw` is wiped when the app re-draws the engine's
|
|
5
|
-
# route set on boot/reload). Mounted by a satellite via:
|
|
5
|
+
# route set on boot/reload). Mounted by a satellite or an authority via:
|
|
6
6
|
#
|
|
7
7
|
# mount McpToolkit::Engine => "/mcp"
|
|
8
8
|
#
|
|
@@ -247,12 +247,27 @@ module McpToolkit::Authority::ControllerMethods
|
|
|
247
247
|
# ---- request handling -----------------------------------------------
|
|
248
248
|
|
|
249
249
|
def mcp_handle_batch(requests)
|
|
250
|
+
mcp_enforce_batch_limit!(requests)
|
|
250
251
|
responses = requests.filter_map { |req| mcp_process_single_request(req) }
|
|
251
252
|
return head :accepted if responses.empty?
|
|
252
253
|
|
|
253
254
|
mcp_render_response(responses)
|
|
254
255
|
end
|
|
255
256
|
|
|
257
|
+
# Rejects an oversized JSON-RPC batch BEFORE any element runs. Rate limiting is
|
|
258
|
+
# a per-HTTP-request before_action, so without this an authenticated caller
|
|
259
|
+
# could fan out unbounded work (N tool executions / N blocking upstream calls)
|
|
260
|
+
# under a single rate-limit tick. Raised as a boundary Protocol::Error →
|
|
261
|
+
# rendered as a JSON-RPC error envelope (null id) by the rescue_from hook.
|
|
262
|
+
# `config.max_batch_size = nil` disables the cap.
|
|
263
|
+
def mcp_enforce_batch_limit!(requests)
|
|
264
|
+
max = mcp_config.max_batch_size
|
|
265
|
+
return if max.nil? || requests.size <= max
|
|
266
|
+
|
|
267
|
+
raise McpToolkit::Protocol::InvalidRequest,
|
|
268
|
+
"JSON-RPC batch too large: #{requests.size} requests exceeds the maximum of #{max}"
|
|
269
|
+
end
|
|
270
|
+
|
|
256
271
|
def mcp_handle_single(request_data)
|
|
257
272
|
response_data = mcp_process_single_request(request_data)
|
|
258
273
|
return head :accepted if response_data.nil?
|
|
@@ -20,11 +20,13 @@ class McpToolkit::Authority::RegistryToolProvider
|
|
|
20
20
|
# actually advertised in `tools/list` and matched in `tools/call` is this base
|
|
21
21
|
# name PREFIXED with `config.generic_tool_name_prefix` (empty by default, so the
|
|
22
22
|
# bare base name), letting a host namespace its generic tools.
|
|
23
|
+
# Alphabetical by base name — the order tools/list advertises them in
|
|
24
|
+
# (matches the pre-gem contract adopting hosts' clients observed).
|
|
23
25
|
TOOLS = {
|
|
24
|
-
"resources" => McpToolkit::Authority::Tools::Resources,
|
|
25
|
-
"resource_schema" => McpToolkit::Authority::Tools::ResourceSchema,
|
|
26
26
|
"get" => McpToolkit::Authority::Tools::Get,
|
|
27
|
-
"list" => McpToolkit::Authority::Tools::List
|
|
27
|
+
"list" => McpToolkit::Authority::Tools::List,
|
|
28
|
+
"resource_schema" => McpToolkit::Authority::Tools::ResourceSchema,
|
|
29
|
+
"resources" => McpToolkit::Authority::Tools::Resources
|
|
28
30
|
}.freeze
|
|
29
31
|
|
|
30
32
|
def initialize(config:)
|
|
@@ -32,9 +34,11 @@ class McpToolkit::Authority::RegistryToolProvider
|
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
# The four static generic tool definitions (context-independent), each advertised
|
|
35
|
-
# under its PREFIXED name so `tools/list` shows the host's namespaced names.
|
|
37
|
+
# under its PREFIXED name so `tools/list` shows the host's namespaced names. The
|
|
38
|
+
# prefix is threaded into each definition so sibling-tool references in the
|
|
39
|
+
# description / input schema name the prefixed tools too (see Tools::Base.definition).
|
|
36
40
|
def tool_definitions(_context)
|
|
37
|
-
TOOLS.map { |
|
|
41
|
+
TOOLS.map { |_base_name, klass| klass.definition(name_prefix: prefix, config: @config) }
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
# A tool instance bound to this provider's config, or nil for an unknown name.
|
|
@@ -54,11 +58,6 @@ class McpToolkit::Authority::RegistryToolProvider
|
|
|
54
58
|
@config.generic_tool_name_prefix.to_s
|
|
55
59
|
end
|
|
56
60
|
|
|
57
|
-
# The advertised name for a base tool: the configured prefix followed by the base.
|
|
58
|
-
def prefixed(base_name)
|
|
59
|
-
"#{prefix}#{base_name}"
|
|
60
|
-
end
|
|
61
|
-
|
|
62
61
|
# Recovers the base tool name from an advertised name by stripping the configured
|
|
63
62
|
# prefix, or nil when the name does not carry the (non-empty) prefix.
|
|
64
63
|
def base_name_for(name)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A `tool_provider` serving exactly one bespoke authority tool alongside the
|
|
4
|
+
# generic Registry-backed ones — the shape every host with a single custom tool
|
|
5
|
+
# would otherwise hand-roll. Used automatically by the composed default
|
|
6
|
+
# provider for bare tool classes in `config.extra_tool_providers`.
|
|
7
|
+
#
|
|
8
|
+
# The tool must expose `.definition` (the tools/list entry) and `.tool_name`,
|
|
9
|
+
# and itself satisfy the dispatcher's tool contract (`required_permissions_scope`
|
|
10
|
+
# + `call`) — e.g. a class built on McpToolkit::Tools::AuthorityBase. It is
|
|
11
|
+
# advertised unconditionally; its scope/authorization gates are enforced at
|
|
12
|
+
# call time (by the dispatcher and the tool itself).
|
|
13
|
+
class McpToolkit::Authority::SingleToolProvider
|
|
14
|
+
def initialize(tool)
|
|
15
|
+
@tool = tool
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def tool_definitions(_context)
|
|
19
|
+
[@tool.definition]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def find(name)
|
|
23
|
+
name.to_s == @tool.tool_name ? @tool : nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -43,9 +43,26 @@ class McpToolkit::Authority::Tools::Base
|
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
# The static tool definition returned by the provider's `tool_definitions`
|
|
46
|
-
# (part of `tools/list`). Generic and context-independent.
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
# (part of `tools/list`). Generic and context-independent. `name_prefix` is
|
|
47
|
+
# the host's `config.generic_tool_name_prefix`: it prefixes the advertised
|
|
48
|
+
# name AND is threaded through every sibling-tool reference in the
|
|
49
|
+
# description / input schema (see McpToolkit::ToolReferenceRewriter).
|
|
50
|
+
# `config` lets a tool adapt its prose to host configuration (see
|
|
51
|
+
# .description_text) — served docs must describe the behavior the host
|
|
52
|
+
# actually configured.
|
|
53
|
+
def definition(name_prefix: "", config: nil)
|
|
54
|
+
{
|
|
55
|
+
name: "#{name_prefix}#{tool_name}",
|
|
56
|
+
description: McpToolkit::ToolReferenceRewriter.rewrite(description_text(config), name_prefix),
|
|
57
|
+
inputSchema: McpToolkit::ToolReferenceRewriter.rewrite(input_schema, name_prefix)
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Hook for a tool whose description depends on host configuration
|
|
62
|
+
# (Tools::List swaps its bare-value grammar per
|
|
63
|
+
# config.bare_filter_value_semantics). Default: the static description.
|
|
64
|
+
def description_text(_config)
|
|
65
|
+
_description
|
|
49
66
|
end
|
|
50
67
|
end
|
|
51
68
|
|
|
@@ -119,4 +136,15 @@ class McpToolkit::Authority::Tools::Base
|
|
|
119
136
|
rescue McpToolkit::Errors::InvalidParams => e
|
|
120
137
|
raise McpToolkit::Protocol::InvalidParams, e.message
|
|
121
138
|
end
|
|
139
|
+
|
|
140
|
+
# Rejects tool arguments outside the declared input schema (-32602) instead of
|
|
141
|
+
# silently ignoring them — a typo'd argument name should surface, not no-op.
|
|
142
|
+
# `list` deliberately does NOT use this: its extra top-level arguments are the
|
|
143
|
+
# resource-specific custom filters. (`account_id` is consumed by the
|
|
144
|
+
# transport's account resolution, so every tool tolerates it explicitly.)
|
|
145
|
+
def reject_unknown_arguments!(extra)
|
|
146
|
+
return if extra.empty?
|
|
147
|
+
|
|
148
|
+
raise McpToolkit::Protocol::InvalidParams, "unknown argument(s): #{extra.keys.join(", ")}"
|
|
149
|
+
end
|
|
122
150
|
end
|
|
@@ -45,7 +45,8 @@ class McpToolkit::Authority::Tools::Get < McpToolkit::Authority::Tools::Base
|
|
|
45
45
|
}
|
|
46
46
|
)
|
|
47
47
|
|
|
48
|
-
def call(context:, resource: nil, id: nil, fields: nil, **
|
|
48
|
+
def call(context:, resource: nil, id: nil, fields: nil, **extra)
|
|
49
|
+
reject_unknown_arguments!(extra.except(:account_id))
|
|
49
50
|
descriptor = resolve_descriptor(resource)
|
|
50
51
|
ensure_resource_accessible!(descriptor, context)
|
|
51
52
|
ensure_scope!(descriptor, context)
|
|
@@ -7,6 +7,35 @@
|
|
|
7
7
|
# custom filters, pagination, and sparse fieldsets are all handled by the reused
|
|
8
8
|
# McpToolkit::ListExecutor.
|
|
9
9
|
class McpToolkit::Authority::Tools::List < McpToolkit::Authority::Tools::Base
|
|
10
|
+
# The bare-value grammar bullet, per config.bare_filter_value_semantics: the
|
|
11
|
+
# served description must state the semantics the host ACTUALLY configured —
|
|
12
|
+
# advertising comma/"null" tokenization to clients of a :literal host would
|
|
13
|
+
# send them filters that silently match nothing. The :tokenized text is the
|
|
14
|
+
# exact bullet embedded in the static description below (spec-pinned), so
|
|
15
|
+
# .description_text can swap it by plain substring substitution; both use
|
|
16
|
+
# `<<-` (no dedent) to carry the description's rendered 2-space indentation.
|
|
17
|
+
BARE_VALUE_GRAMMAR = {
|
|
18
|
+
tokenized: <<-TEXT.rstrip,
|
|
19
|
+
- A bare value matches by equality. A comma-separated string or an array of scalars
|
|
20
|
+
matches ANY of the values (IN), e.g. { "status": "booked,canceled" } or
|
|
21
|
+
{ "status": ["booked", "canceled"] }. The string "null" (or a JSON null) matches
|
|
22
|
+
records where the value is NULL.
|
|
23
|
+
TEXT
|
|
24
|
+
literal: <<-TEXT.rstrip
|
|
25
|
+
- A bare value matches by equality, LITERALLY: a comma-separated string is a single
|
|
26
|
+
value and the string "null" is the literal string. A JSON null matches records
|
|
27
|
+
where the value is NULL. An array of scalars matches ANY of its values (IN).
|
|
28
|
+
TEXT
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# Swaps the bare-value bullet for the host's configured semantics; the rest
|
|
32
|
+
# of the description is mode-independent.
|
|
33
|
+
def self.description_text(config)
|
|
34
|
+
return _description unless config && config.bare_filter_value_semantics == :literal
|
|
35
|
+
|
|
36
|
+
_description.sub(BARE_VALUE_GRAMMAR[:tokenized], BARE_VALUE_GRAMMAR[:literal])
|
|
37
|
+
end
|
|
38
|
+
|
|
10
39
|
tool_name "list"
|
|
11
40
|
description <<~DESC.strip
|
|
12
41
|
Fetch a paginated list of records from a read-only resource. Pass the resource name as
|
|
@@ -23,6 +52,22 @@ class McpToolkit::Authority::Tools::List < McpToolkit::Authority::Tools::Base
|
|
|
23
52
|
- filter: an object of { <key>: <value> } filters, applied ON TOP of the account scope
|
|
24
53
|
(they can only narrow, never widen). Each resource advertises its available filter keys
|
|
25
54
|
and operators via `resource_schema`. Unknown keys are rejected.
|
|
55
|
+
- A bare value matches by equality. A comma-separated string or an array of scalars
|
|
56
|
+
matches ANY of the values (IN), e.g. { "status": "booked,canceled" } or
|
|
57
|
+
{ "status": ["booked", "canceled"] }. The string "null" (or a JSON null) matches
|
|
58
|
+
records where the value is NULL.
|
|
59
|
+
- An operator condition is an object { "op": <operator>, "value": <value> }, e.g.
|
|
60
|
+
{ "price": { "op": "gteq", "value": 100 } }. An array of conditions ANDs them into a
|
|
61
|
+
range: { "price": [{ "op": "gteq", "value": 100 }, { "op": "lt", "value": 200 }] }.
|
|
62
|
+
Each attribute's supported operators are listed in `resource_schema`.
|
|
63
|
+
- Some filter keys require a companion key (e.g. a polymorphic id and its type) —
|
|
64
|
+
`resource_schema` advertises these under a relationship's `filter.requires`; pass
|
|
65
|
+
both keys together.
|
|
66
|
+
|
|
67
|
+
Resource-specific filters:
|
|
68
|
+
- Some resources accept additional filters advertised in `resource_schema` under
|
|
69
|
+
`resource_filters`. Pass each as a TOP-LEVEL argument (NOT inside `filter`), e.g.
|
|
70
|
+
{ "resource": "...", "<name>": <value> }.
|
|
26
71
|
|
|
27
72
|
Sparse fieldset:
|
|
28
73
|
- fields: names of the attributes and/or relationships to include in each record, as an
|
|
@@ -67,7 +112,10 @@ class McpToolkit::Authority::Tools::List < McpToolkit::Authority::Tools::Base
|
|
|
67
112
|
"field. Include \"id\" if you need it. Unknown names are rejected."
|
|
68
113
|
}
|
|
69
114
|
},
|
|
70
|
-
required: ["resource"]
|
|
115
|
+
required: ["resource"],
|
|
116
|
+
# Resource-specific filters (resource_schema's `resource_filters`) arrive as
|
|
117
|
+
# top-level arguments, so the schema must not advertise a closed shape.
|
|
118
|
+
additionalProperties: true
|
|
71
119
|
}
|
|
72
120
|
)
|
|
73
121
|
|
|
@@ -16,7 +16,14 @@ class McpToolkit::Authority::Tools::ResourceSchema < McpToolkit::Authority::Tool
|
|
|
16
16
|
- relationships: associated resources emitted in the record's `links`; each names the
|
|
17
17
|
`target_resource` it resolves to (callable via `list`/`get`)
|
|
18
18
|
- standard_filters: ids, updated_since, limit, offset (accepted by the `list` tool)
|
|
19
|
-
- filters: the per-attribute equality filter keys the `list` tool accepts
|
|
19
|
+
- filters: the per-attribute equality/operator filter keys the `list` tool accepts in
|
|
20
|
+
its `filter` argument
|
|
21
|
+
- resource_filters: resource-specific filters, if any — each is passed as a TOP-LEVEL
|
|
22
|
+
argument of the `list` tool (NOT inside `filter`), e.g. { "resource": "...",
|
|
23
|
+
"<name>": <value> }
|
|
24
|
+
- filter_examples: ready-to-use `filter` payloads for this resource
|
|
25
|
+
A relationship's `filter` block lists the keys that filter by it; when it names a
|
|
26
|
+
`requires` key (e.g. a polymorphic id needing its type), pass BOTH keys together.
|
|
20
27
|
The `attributes` and `relationships` names are also the valid values for the `fields` sparse
|
|
21
28
|
fieldset argument on `get` / `list`. Call this before `list` to learn a resource's shape.
|
|
22
29
|
DESC
|
|
@@ -34,7 +41,8 @@ class McpToolkit::Authority::Tools::ResourceSchema < McpToolkit::Authority::Tool
|
|
|
34
41
|
}
|
|
35
42
|
)
|
|
36
43
|
|
|
37
|
-
def call(context:, resource: nil, **
|
|
44
|
+
def call(context:, resource: nil, **extra)
|
|
45
|
+
reject_unknown_arguments!(extra.except(:account_id))
|
|
38
46
|
descriptor = resolve_descriptor(resource)
|
|
39
47
|
ensure_resource_accessible!(descriptor, context)
|
|
40
48
|
ensure_scope!(descriptor, context)
|
|
@@ -11,15 +11,23 @@ class McpToolkit::Authority::Tools::Resources < McpToolkit::Authority::Tools::Ba
|
|
|
11
11
|
tool_name "resources"
|
|
12
12
|
description <<~DESC.strip
|
|
13
13
|
List all read-only resources available via the `list` and `get` tools. Returns each
|
|
14
|
-
resource's name
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
resource's name, a short description, whether it accepts filters (`filterable`) and — when
|
|
15
|
+
present — a usage `note` (read it before interpreting the resource's data). Call this once
|
|
16
|
+
at the start of a session to learn what exists, then use `resource_schema` for a specific
|
|
17
|
+
resource's attributes, relationships and filters.
|
|
17
18
|
DESC
|
|
18
19
|
|
|
20
|
+
# Unlike get / resource_schema (strict kwargs pre-gem), the pre-gem resources
|
|
21
|
+
# tool tolerated ANY extra argument — so this one stays tolerant.
|
|
19
22
|
def call(context:, **_args)
|
|
20
23
|
{
|
|
21
24
|
resources: visible_resources(context).map do |resource|
|
|
22
|
-
{
|
|
25
|
+
{
|
|
26
|
+
name: resource.name,
|
|
27
|
+
description: resource.description,
|
|
28
|
+
filterable: filterable?(resource),
|
|
29
|
+
note: resource.note
|
|
30
|
+
}.compact
|
|
23
31
|
end
|
|
24
32
|
}
|
|
25
33
|
end
|
|
@@ -30,4 +38,18 @@ class McpToolkit::Authority::Tools::Resources < McpToolkit::Authority::Tools::Ba
|
|
|
30
38
|
def visible_resources(context)
|
|
31
39
|
registry.resources.reject { |resource| resource.superusers_only? && !context.superuser? }
|
|
32
40
|
end
|
|
41
|
+
|
|
42
|
+
# Whether the resource can be filtered at all — via the generic allowlist OR a
|
|
43
|
+
# resource-specific custom filter. Reading `filterable_columns` resolves a
|
|
44
|
+
# lazily-declared allowlist, which may run host code (e.g. a DB-backed column
|
|
45
|
+
# list). One resource's failing resolution must not take down the whole
|
|
46
|
+
# discovery index — this is the tool every session calls first — so a raise
|
|
47
|
+
# degrades to nil (the `filterable` key is omitted for that resource) and the
|
|
48
|
+
# unresolved source is retried on the next read (see Resource#filterable).
|
|
49
|
+
def filterable?(resource)
|
|
50
|
+
resource.filterable_columns.any? || resource.custom_filters.any?
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
config.logger&.warn("mcp_toolkit: filterable resolution failed for #{resource.name}: #{e.message}")
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
33
55
|
end
|