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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +231 -0
  3. data/config/routes.rb +1 -1
  4. data/lib/mcp_toolkit/authority/controller_methods.rb +15 -0
  5. data/lib/mcp_toolkit/authority/registry_tool_provider.rb +9 -10
  6. data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
  7. data/lib/mcp_toolkit/authority/tools/base.rb +31 -3
  8. data/lib/mcp_toolkit/authority/tools/get.rb +2 -1
  9. data/lib/mcp_toolkit/authority/tools/list.rb +49 -1
  10. data/lib/mcp_toolkit/authority/tools/resource_schema.rb +10 -2
  11. data/lib/mcp_toolkit/authority/tools/resources.rb +26 -4
  12. data/lib/mcp_toolkit/configuration.rb +161 -7
  13. data/lib/mcp_toolkit/dispatcher.rb +17 -3
  14. data/lib/mcp_toolkit/engine.rb +5 -2
  15. data/lib/mcp_toolkit/engine_controllers.rb +11 -3
  16. data/lib/mcp_toolkit/filtering.rb +168 -23
  17. data/lib/mcp_toolkit/gateway/aggregator.rb +25 -5
  18. data/lib/mcp_toolkit/list_executor.rb +48 -4
  19. data/lib/mcp_toolkit/resource.rb +55 -6
  20. data/lib/mcp_toolkit/resource_schema.rb +125 -16
  21. data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
  22. data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
  23. data/lib/mcp_toolkit/session.rb +2 -2
  24. data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
  25. data/lib/mcp_toolkit/tools/authority_base.rb +23 -2
  26. data/lib/mcp_toolkit/tools/base.rb +23 -3
  27. data/lib/mcp_toolkit/tools/get.rb +1 -1
  28. data/lib/mcp_toolkit/tools/list.rb +27 -9
  29. data/lib/mcp_toolkit/tools/resource_schema.rb +12 -3
  30. data/lib/mcp_toolkit/tools/resources.rb +30 -7
  31. data/lib/mcp_toolkit/usage_metering/recorder.rb +28 -2
  32. data/lib/mcp_toolkit/version.rb +1 -1
  33. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e99039b1f2109b3da40c5d271338fbefc565bce7fb472b36947caf322d0ab3f8
4
- data.tar.gz: db79d656f89ce7566e5437956a8f0feac0ba9f4a906896078db3cee4879b512b
3
+ metadata.gz: d9e89469906c5d75ea46174ecf1e7368ffbe93669ec80526e559f683c778516b
4
+ data.tar.gz: 6c97881a9edbd3df1185069364c99457e90fecb4ecf95a7a6c85612e9f5d0d2e
5
5
  SHA512:
6
- metadata.gz: '0804f99b21ac1cf54aa5d379957b1a3bec5af4a2aa3630bb2c0210d3b594cb98cf56dc3af4cc3693e1d872b2855ade1f1f2dc97a03760f56d16af85994f56547'
7
- data.tar.gz: 07626c80512de4d475cd1cd4b7fbe31a210718dbc8a5d2b81a97009900f8cc1df91b79cae903bfc1b0769a049ab224232cccadfd2b86f61c50ce83e5e08766f5
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 { |base_name, klass| klass.definition.merge(name: prefixed(base_name)) }
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
- def definition
48
- { name: tool_name, description: _description, inputSchema: input_schema }
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, **_args)
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, **_args)
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 and a short description. Call this once at the start of a session to learn
15
- what exists, then use `resource_schema` for a specific resource's attributes and
16
- relationships.
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
- { name: resource.name, description: resource.description }
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