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
@@ -169,6 +169,94 @@ class McpToolkit::Configuration
169
169
  # @return [#sanitize_sql_like]
170
170
  attr_accessor :sql_sanitizer
171
171
 
172
+ # NOTE: (all data-path settings below): the list/get executors and the schema
173
+ # builder read the PROCESS-GLOBAL `McpToolkit.config` — a per-instance config
174
+ # bound to a provider affects tool prose only. Configure these globally.
175
+ #
176
+ # How a BARE (non-operator) filter value is interpreted by the list executor:
177
+ #
178
+ # :tokenized (default) — a comma-separated string becomes an IN set, the
179
+ # "null" token (and a JSON null) matches NULL rows, an Array of non-null
180
+ # scalars is an IN set (nil/Hash/nested-Array elements rejected), and an
181
+ # empty string means "no filter".
182
+ # :literal — the value is handed to the WHERE clause verbatim (an op-less
183
+ # Hash is still rejected). This preserves an EXISTING API contract for a
184
+ # host whose pre-gem endpoint matched bare values literally: "a,b" is one
185
+ # literal string, "null" is the literal string, "" matches empty-string
186
+ # rows, an Array (including nil elements) gets the adapter's native IN /
187
+ # OR-IS-NULL handling.
188
+ #
189
+ # Operator conditions ({ op:, value: }) behave identically in both modes.
190
+ #
191
+ # @return [Symbol] :tokenized or :literal
192
+ attr_accessor :bare_filter_value_semantics
193
+
194
+ # Default ordering for a resource whose primary key is NON-numeric (numeric
195
+ # PKs always order by :id):
196
+ #
197
+ # :created_at (default) — ORDER BY created_at, <pk> (chronological pages
198
+ # with a total-order tiebreaker).
199
+ # :primary_key — ORDER BY <pk> only. Preserves an EXISTING API contract for
200
+ # a host whose pre-gem endpoint ordered every list by id.
201
+ #
202
+ # @return [Symbol] :created_at or :primary_key
203
+ attr_accessor :non_numeric_pk_order
204
+
205
+ # Per-column-type overrides for the operator sets advertised by
206
+ # `resource_schema` and enforced by the list executor, merged over
207
+ # McpToolkit::Filtering::OPERATORS_BY_TYPE. Lets a host preserve an EXISTING
208
+ # operator contract exactly — both the schema bytes and which conditions are
209
+ # accepted — e.g. `{ text: %w[eq in], date: %w[eq in] }` for a pre-gem
210
+ # endpoint that never offered comparisons on those types. Empty by default
211
+ # (the gem's own sets apply).
212
+ #
213
+ # @return [Hash{Symbol => Array<String>}]
214
+ attr_reader :filter_operator_overrides
215
+
216
+ # Assigns per-type operator overrides, rejecting any operator the gem cannot
217
+ # safely dispatch. Only McpToolkit::Filtering::AREL_PREDICATIONS map onto an
218
+ # Arel predication that binds/quotes its value; anything else (e.g. "extract")
219
+ # would be public_send to an Arel attribute with the request value passed
220
+ # through VERBATIM — an SQL-injection surface. Fail fast at config time so a
221
+ # typo can never open that door at request time.
222
+ def filter_operator_overrides=(overrides)
223
+ overrides ||= {}
224
+ unless overrides.is_a?(Hash)
225
+ raise ArgumentError,
226
+ "filter_operator_overrides must be a Hash of { column_type => [operator, ...] }, " \
227
+ "got #{overrides.class}"
228
+ end
229
+
230
+ overrides.each do |type, operators|
231
+ unsupported = Array(operators).map(&:to_s) - McpToolkit::Filtering::AREL_PREDICATIONS
232
+ next if unsupported.empty?
233
+
234
+ raise ArgumentError,
235
+ "filter_operator_overrides[#{type.inspect}] has unsupported operator(s): " \
236
+ "#{unsupported.join(", ")}. Allowed: #{McpToolkit::Filtering::AREL_PREDICATIONS.join(", ")}."
237
+ end
238
+
239
+ @filter_operator_overrides = overrides
240
+ end
241
+
242
+ # Caps how many values an IN-set filter may resolve to, and how many operator
243
+ # conditions may be ANDed on a single attribute, so a valid token can't emit
244
+ # an unbounded IN clause / AND-chain (oversized SQL + Arel AST and expensive
245
+ # planning; rate limiting is opt-in via rate_limit_max_requests). Applies to
246
+ # the default :tokenized bare-value semantics and to { op:, value: }
247
+ # conditions. nil disables the cap.
248
+ #
249
+ # @return [Integer, nil]
250
+ attr_accessor :max_filter_values
251
+
252
+ # Caps how many JSON-RPC calls a single POST batch may carry on the authority
253
+ # transport. Rate limiting is per-HTTP-request, so an uncapped batch would let
254
+ # one request fan out unbounded work (N tool executions / N upstream calls)
255
+ # under a single rate-limit tick. nil disables the cap.
256
+ #
257
+ # @return [Integer, nil]
258
+ attr_accessor :max_batch_size
259
+
172
260
  # --- protocol / transport --------------------------------------------------
173
261
 
174
262
  # @return [String, nil] protocol version to pin on the underlying MCP::Server.
@@ -277,11 +365,31 @@ class McpToolkit::Configuration
277
365
  # where a tool object responds to `#required_permissions_scope` (String|nil, the
278
366
  # gem's scope gate) and `#call(context:, **arguments)` (returns Hash|String,
279
367
  # which the gem wraps into `{ content: [{ type: "text", text: }] }`). See
280
- # McpToolkit::Tools::AuthorityBase for a base that satisfies this. nil = the host
281
- # contributes no own tools (a pure gateway).
368
+ # McpToolkit::Tools::AuthorityBase for a base that satisfies this.
282
369
  #
283
- # @return [#tool_definitions, #find, nil]
284
- attr_accessor :tool_provider
370
+ # UNSET (the default), the provider is composed automatically: the generic
371
+ # RegistryToolProvider (bound to this config) first, then every entry of
372
+ # `extra_tool_providers` — so a host that only serves the generic tools plus
373
+ # its own bespoke ones needs no provider plumbing at all. Assign explicitly to
374
+ # take full control of the catalog.
375
+ #
376
+ # @return [#tool_definitions, #find]
377
+ attr_writer :tool_provider
378
+
379
+ def tool_provider
380
+ @tool_provider || composed_tool_provider
381
+ end
382
+
383
+ # Additional tool providers (or bare TOOL classes, auto-wrapped in a
384
+ # SingleToolProvider) composed AFTER the generic Registry-backed tools when
385
+ # `tool_provider` is not explicitly assigned. The registry provider is always
386
+ # first, so a generic tool name resolves to it; extras only answer their own
387
+ # names.
388
+ #
389
+ # config.extra_tool_providers = [MyApp::Tools::AuditLog]
390
+ #
391
+ # @return [Array<#tool_definitions, Class>]
392
+ attr_accessor :extra_tool_providers
285
393
 
286
394
  # --- generic tool naming ---------------------------------------------------
287
395
 
@@ -327,9 +435,7 @@ class McpToolkit::Configuration
327
435
  @token_authenticator = nil
328
436
 
329
437
  @cache_store = ActiveSupport::Cache::MemoryStore.new
330
- @session_ttl = 3600 # 1 hour
331
-
332
- @sql_sanitizer = McpToolkit::SqlSanitizer.new
438
+ initialize_data_path_defaults
333
439
 
334
440
  @protocol_version = nil
335
441
  @supported_protocol_versions = McpToolkit::Protocol::SUPPORTED_VERSIONS
@@ -348,6 +454,40 @@ class McpToolkit::Configuration
348
454
  @upstreams = McpToolkit::Gateway::UpstreamRegistry.new
349
455
  end
350
456
 
457
+ # Session-TTL and list-executor defaults: the :tokenized / :created_at
458
+ # data-path semantics (a host preserving a pre-gem contract overrides these —
459
+ # see each accessor's docs).
460
+ def initialize_data_path_defaults
461
+ @session_ttl = 3600 # 1 hour
462
+
463
+ @sql_sanitizer = McpToolkit::SqlSanitizer.new
464
+ @bare_filter_value_semantics = :tokenized
465
+ @non_numeric_pk_order = :created_at
466
+ @filter_operator_overrides = {}
467
+ @max_filter_values = 500
468
+ @max_batch_size = 50
469
+ end
470
+
471
+ # The default authority tool catalog when no explicit `tool_provider` is
472
+ # assigned: the generic Registry-backed tools first (so a generic name always
473
+ # resolves to them; included only when the host registered resources — a pure
474
+ # gateway keeps contributing nothing), then each extra provider — a bare tool
475
+ # CLASS is wrapped in a SingleToolProvider. Built per read (cheap, stateless
476
+ # providers), so a reload that re-assigns `extra_tool_providers` or registers
477
+ # resources takes effect immediately.
478
+ def composed_tool_provider
479
+ providers = []
480
+ providers << McpToolkit::Authority::RegistryToolProvider.new(config: self) if registry.resources.any?
481
+ Array(extra_tool_providers).each do |provider|
482
+ providers << (provider.respond_to?(:tool_definitions) ? provider : McpToolkit::Authority::SingleToolProvider.new(provider))
483
+ end
484
+ return nil if providers.empty?
485
+ return providers.first if providers.one?
486
+
487
+ McpToolkit::Authority::CompositeToolProvider.new(*providers)
488
+ end
489
+ private :composed_tool_provider
490
+
351
491
  # The authority transport's injection points all default to nil (a no-op): a
352
492
  # pure satellite/gateway never touches them. `rate_limit_window` is the sole
353
493
  # non-nil default (the window size only matters once a cap opts in).
@@ -357,6 +497,7 @@ class McpToolkit::Configuration
357
497
  @usage_flusher = nil
358
498
  @session_data_builder = nil
359
499
  @tool_provider = nil
500
+ @extra_tool_providers = []
360
501
  @rate_limit_max_requests = nil # nil = rate limiting disabled
361
502
  @rate_limit_window = 3600 # 1 hour
362
503
  @superuser_resolver = nil # nil = duck-type principal.superuser?
@@ -372,6 +513,19 @@ class McpToolkit::Configuration
372
513
  upstreams.register(key:, url:, public_tool_list:)
373
514
  end
374
515
 
516
+ # Declares the gateway's upstreams from an ENV-var map — `{ key => env var
517
+ # name }` — the shape every authority host repeats: resets the registry first
518
+ # (idempotent across code reloads, where the registration typically re-runs in
519
+ # a `to_prepare`), and an upstream whose ENV url is blank is never registered,
520
+ # so an unconfigured environment behaves like no-upstreams. `env` is
521
+ # injectable for tests.
522
+ #
523
+ # config.register_upstreams_from_env("billing" => "BILLING_MCP_URL")
524
+ def register_upstreams_from_env(mapping, env: ENV)
525
+ upstreams.reset!
526
+ mapping.each { |key, env_var| register_upstream(key:, url: env[env_var.to_s]) }
527
+ end
528
+
375
529
  # The gateway handshake client name, defaulting to the server identity when the
376
530
  # host hasn't split it. Read (not stored) so a `server_name` change before the
377
531
  # split is set still flows through.
@@ -42,9 +42,13 @@ class McpToolkit::Dispatcher
42
42
  config.logger&.error("MCP dispatcher error: #{e.message}\n#{e.backtrace&.join("\n")}")
43
43
  return nil unless request.key?("id")
44
44
 
45
+ # A generic message to the caller: the raw exception text of an UNEXPECTED
46
+ # error (ActiveRecord::StatementInvalid carrying SQL, a NoMethodError naming
47
+ # internal classes, an internal hostname) must not leak. Full detail is in
48
+ # the log line above.
45
49
  McpToolkit::Protocol.error_response(
46
50
  id: request["id"],
47
- error: McpToolkit::Protocol::InternalError.new(e.message)
51
+ error: McpToolkit::Protocol::InternalError.new("Internal error")
48
52
  )
49
53
  end
50
54
 
@@ -189,8 +193,13 @@ class McpToolkit::Dispatcher
189
193
  # JSON gives string keys; a tool's `call(context:, **arguments)` needs symbol
190
194
  # keys for the keyword splat. Deep-symbolized so nested argument hashes reach
191
195
  # the tool in the same shape a symbol-keyed caller would pass.
196
+ #
197
+ # `context` is stripped: a tool is invoked as `call(context:, **arguments)`, and
198
+ # a splatted keyword OVERRIDES the explicit one, so a caller-supplied `context`
199
+ # argument would replace the gem-resolved Authority::Context with attacker JSON
200
+ # (auth-context injection). `context` is never a legitimate tool argument.
192
201
  def symbolized_arguments(arguments)
193
- arguments.to_h.deep_symbolize_keys
202
+ arguments.to_h.deep_symbolize_keys.except(:context)
194
203
  end
195
204
 
196
205
  def ensure_tool_scope!(tool)
@@ -228,7 +237,12 @@ class McpToolkit::Dispatcher
228
237
  data: error.jsonrpc_error["data"]
229
238
  )
230
239
  else
231
- McpToolkit::Protocol::InternalError.new(error.message)
240
+ # A TRANSPORT failure (no upstream JSON-RPC error): its message embeds the
241
+ # internal upstream host:port (Faraday/Net::HTTP "Failed to open TCP
242
+ # connection to <host>:<port>"). The proxy already logged the detail
243
+ # (Gateway::Proxy#log_proxied_failure), so relay only a generic error —
244
+ # never the internal topology — to the caller.
245
+ McpToolkit::Protocol::InternalError.new("Internal error")
232
246
  end
233
247
  end
234
248
  end
@@ -3,12 +3,15 @@
3
3
  # Mountable Rails engine that draws the MCP transport routes plus the authority
4
4
  # introspection route (defined in the engine's config/routes.rb so they survive
5
5
  # Rails' route reloads) against the gem-provided McpToolkit::ServerController /
6
- # McpToolkit::TokensController. A satellite mounts it in one line:
6
+ # McpToolkit::TokensController. A satellite OR an authority mounts it in one line:
7
7
  #
8
8
  # # config/routes.rb
9
9
  # mount McpToolkit::Engine => "/mcp"
10
10
  #
11
- # yielding exactly the endpoints a hand-rolled satellite declared:
11
+ # The mounted McpToolkit::ServerController is role-aware (built lazily from
12
+ # config.auth_role): an authority host gets the hand-rolled dispatcher path, a
13
+ # satellite gets the SDK-backed one — see engine_controllers.rb. This yields
14
+ # exactly the endpoints a hand-rolled host declared:
12
15
  #
13
16
  # POST /mcp -> create (JSON-RPC requests/responses)
14
17
  # GET /mcp -> stream (405; no server-initiated SSE)
@@ -49,10 +49,18 @@ module McpToolkit
49
49
  end
50
50
  end
51
51
 
52
- # The SATELLITE transport controller the engine mounts (unchanged behavior; the
53
- # SDK-backed path). Built lazily only so its parent is read after config.
52
+ # The transport controller the engine mounts at POST/GET/DELETE /mcp, chosen by
53
+ # ROLE so a single `mount McpToolkit::Engine => "/mcp"` works for either kind of
54
+ # host: an AUTHORITY (`auth_role == :authority`) gets the hand-rolled dispatcher
55
+ # path (Authority::ControllerMethods — local token auth, gateway proxying, usage
56
+ # metering, rate limiting), a SATELLITE (the default) gets the SDK-backed path
57
+ # (Transport::ControllerMethods, which forwards tokens to a central app). Built
58
+ # lazily so both `config.parent_controller` and `config.auth_role` are read after
59
+ # the host's initializer/to_prepare has run. A host that would rather draw its own
60
+ # routes still subclasses McpToolkit::Authority::ServerController directly.
54
61
  def self.build_server_controller(parent)
55
- Class.new(parent) { include McpToolkit::Transport::ControllerMethods }
62
+ concern = config.authority? ? McpToolkit::Authority::ControllerMethods : McpToolkit::Transport::ControllerMethods
63
+ Class.new(parent) { include concern }
56
64
  end
57
65
 
58
66
  # The AUTHORITY introspection endpoint the engine mounts at
@@ -4,15 +4,21 @@
4
4
  # the following semantics:
5
5
  #
6
6
  # * a BARE value filters by equality: filter: { booking_id: 42 }
7
- # (a comma-separated string becomes an IN lookup: filter: { status: "a,b" })
7
+ # - a comma-separated string becomes an IN lookup: filter: { status: "a,b" }
8
+ # - an Array of scalars becomes an IN lookup too: filter: { status: ["a", "b"] }
9
+ # - the string "null" (or a JSON null) matches NULL rows: filter: { canceled_at: "null" }
10
+ # (as a SCALAR value only — inside an IN set, elements must be non-null
11
+ # scalars and "null" stays a literal string, because SQL `IN` cannot match
12
+ # NULL; a null-or-nothing condition is expressed as a scalar filter)
8
13
  # * an { op:, value: } HASH filters with an operator: filter: { price: { op: "gteq", value: 100 } }
9
14
  # * an ARRAY of those hashes ANDs them (ranges):
10
15
  # filter: { price: [{ op: "gteq", value: 100 }, { op: "lt", value: 200 }] }
11
16
  #
12
17
  # Supported operators (validated against the column's DB type):
13
- # eq, not_eq, gt, gteq, lt, lteq — numeric / datetime columns
18
+ # eq, not_eq, gt, gteq, lt, lteq — numeric / datetime columns (+ in for date)
14
19
  # eq, not_eq, in, matches, does_not_match — string columns (matches => case-insensitive LIKE)
15
20
  # eq, not_eq — boolean columns
21
+ # eq, in — any other column type (uuid, enum, jsonb, ...)
16
22
  #
17
23
  # Allowlist-safe: only the resource's declared `filterable` keys may be filtered,
18
24
  # each resolved to its backing column. Unknown keys are rejected upstream by the
@@ -24,17 +30,29 @@ module McpToolkit::Filtering
24
30
  float: %w[eq not_eq gt gteq lt lteq].freeze,
25
31
  decimal: %w[eq not_eq gt gteq lt lteq].freeze,
26
32
  datetime: %w[eq not_eq gt gteq lt lteq].freeze,
27
- date: %w[eq not_eq gt gteq lt lteq].freeze,
28
- string: %w[eq not_eq in matches does_not_match].freeze,
29
- text: %w[eq not_eq in matches does_not_match].freeze,
33
+ date: %w[eq not_eq gt gteq lt lteq in].freeze,
34
+ string: %w[eq in not_eq matches does_not_match].freeze,
35
+ text: %w[eq in not_eq matches does_not_match].freeze,
30
36
  boolean: %w[eq not_eq].freeze
31
37
  }.freeze
32
38
 
39
+ # Operators for a column type OUTSIDE the table above (uuid, enum, jsonb,
40
+ # citext, ...): plain equality/IN still work on any column, so they stay
41
+ # available rather than turning "cannot be filtered with operators" — which
42
+ # also matches the API contract this replaces for adopting hosts.
43
+ DEFAULT_OPERATORS = %w[eq in].freeze
44
+
33
45
  # Operators that map straight onto an Arel predication method.
34
46
  AREL_PREDICATIONS = %w[eq not_eq gt gteq lt lteq in matches does_not_match].freeze
35
47
 
36
48
  NULL_TOKEN = "null"
37
49
 
50
+ # Operators for which a null value is meaningful: eq/in render `IS NULL`,
51
+ # not_eq renders `IS NOT NULL`. Every other operator rejects null explicitly —
52
+ # a comparison or LIKE against NULL can never match a row in SQL, so passing
53
+ # one through silently would return a wrong (empty) result.
54
+ NULL_ACCEPTING_OPERATORS = %w[eq in not_eq].freeze
55
+
38
56
  # @param relation the already account-scoped relation
39
57
  # @param column [Symbol] the backing DB column (already resolved from the allowlist)
40
58
  # @param value the filter value: a bare value, an { op:, value: } hash, or an
@@ -46,14 +64,40 @@ module McpToolkit::Filtering
46
64
  if compound?(value)
47
65
  apply_condition(relation, column, value, config:)
48
66
  elsif collection?(value)
67
+ enforce_filter_limit!(value.length, config)
49
68
  value.inject(relation) { |rel, condition| apply_condition(rel, column, condition, config:) }
69
+ elsif mixed_collection?(value)
70
+ raise McpToolkit::Errors::InvalidParams,
71
+ "a filter array must contain either only { op:, value: } conditions or only bare values"
50
72
  else
51
- # Bare value: equality. A comma-separated string becomes an IN lookup,
52
- # matching the implicit `eq` semantics.
53
- relation.where(column => equality_value(value))
73
+ # Bare value(s): equality. Under the default :tokenized semantics a
74
+ # comma-separated string or an Array of scalars becomes an IN lookup and
75
+ # "null" / a JSON null matches NULL rows; under :literal the value is
76
+ # handed to the WHERE clause verbatim (see
77
+ # Configuration#bare_filter_value_semantics).
78
+ relation.where(column => bare_value(value, config))
54
79
  end
55
80
  end
56
81
 
82
+ def self.bare_value(value, config)
83
+ if value.is_a?(Hash)
84
+ raise McpToolkit::Errors::InvalidParams,
85
+ "unsupported filter value; use a bare scalar, an array of scalars, " \
86
+ "or { op:, value: } condition(s)"
87
+ end
88
+
89
+ # An Array bare value renders as `WHERE column IN (...)` under EITHER
90
+ # semantics, so bound its size before the branch below. The :literal path
91
+ # returns the array verbatim and would otherwise skip the limit that
92
+ # equality_value/equality_value_set enforce only on the :tokenized path —
93
+ # defeating the query-complexity guard on the new host-compatibility path.
94
+ enforce_filter_limit!(value.length, config) if value.is_a?(Array)
95
+
96
+ return value if config.bare_filter_value_semantics == :literal
97
+
98
+ equality_value(value, config:)
99
+ end
100
+
57
101
  # A single operator-based condition, e.g. { op: "gt", value: 1000 }.
58
102
  def self.compound?(value)
59
103
  condition_hash?(value) && (value.key?(:op) || value.key?("op"))
@@ -64,6 +108,13 @@ module McpToolkit::Filtering
64
108
  value.is_a?(Array) && value.any? && value.all? { |element| compound?(element) }
65
109
  end
66
110
 
111
+ # An Array mixing operator conditions with bare values — rejected explicitly:
112
+ # neither the AND-of-conditions nor the IN-set reading fits, and treating it as
113
+ # either would silently match the wrong rows.
114
+ def self.mixed_collection?(value)
115
+ value.is_a?(Array) && value.any? { |element| compound?(element) }
116
+ end
117
+
67
118
  def self.condition_hash?(value)
68
119
  value.is_a?(Hash)
69
120
  end
@@ -73,7 +124,7 @@ module McpToolkit::Filtering
73
124
  raise McpToolkit::Errors::InvalidParams, "a filter operator is required" if operator.empty?
74
125
 
75
126
  type = column_type(relation, column)
76
- validate_operator!(operator, type, column)
127
+ validate_operator!(operator, type, column, config:)
77
128
 
78
129
  raw = fetch(condition, :value)
79
130
  relation.where(predicate_for(relation, column, operator, raw, config:))
@@ -94,12 +145,24 @@ module McpToolkit::Filtering
94
145
  model.columns_hash[column.to_s]&.type
95
146
  end
96
147
 
97
- def self.validate_operator!(operator, type, column)
98
- allowed = OPERATORS_BY_TYPE[type]
99
- if allowed.nil?
148
+ # Operators an attribute of the given column type accepts — the single source
149
+ # for both the schema's advertisement and the executor's enforcement, so the
150
+ # two can never disagree. `[]` for an attribute with no backing column (which
151
+ # cannot be filtered with operators). A host can override sets per type via
152
+ # config.filter_operator_overrides (e.g. to preserve a pre-gem contract).
153
+ def self.operators_for(type, config: McpToolkit.config)
154
+ return [] if type.nil?
155
+
156
+ config.filter_operator_overrides.fetch(type) { OPERATORS_BY_TYPE.fetch(type, DEFAULT_OPERATORS) }
157
+ end
158
+
159
+ def self.validate_operator!(operator, type, column, config:)
160
+ if type.nil?
100
161
  raise McpToolkit::Errors::InvalidParams,
101
162
  "'#{column}' cannot be filtered with operators"
102
163
  end
164
+
165
+ allowed = operators_for(type, config:)
103
166
  return if allowed.include?(operator)
104
167
 
105
168
  raise McpToolkit::Errors::InvalidParams,
@@ -113,25 +176,55 @@ module McpToolkit::Filtering
113
176
  # test fake) receives a portable Predicate value object it knows how to apply.
114
177
  def self.predicate_for(relation, column, operator, raw, config:)
115
178
  value = normalize_value(operator, raw, config:)
116
- arel_operator = operator == "eq" ? "in" : operator
179
+ method = arel_operator(operator, value)
180
+
181
+ # Defense in depth: only a known-safe Arel predication may ever reach
182
+ # public_send. operators_for/validate_operator! already gate this and
183
+ # config.filter_operator_overrides is validated at assignment — but the
184
+ # value handed to any operator outside eq/in/matches is passed through
185
+ # VERBATIM (normalize_value's else branch), so an operator that slipped in
186
+ # (e.g. a host-configured "extract") must never be dispatched: Arel would
187
+ # interpolate the request value as raw SQL. This is the last guard before
188
+ # the metaprogramming call.
189
+ unless AREL_PREDICATIONS.include?(method)
190
+ raise McpToolkit::Errors::InvalidParams, "'#{operator}' is not a supported filter operator"
191
+ end
117
192
 
118
193
  model = relation.respond_to?(:model) ? relation.model : nil
119
194
  if model.respond_to?(:arel_table)
120
- model.arel_table[column.to_sym].public_send(arel_operator, value)
195
+ model.arel_table[column.to_sym].public_send(method, value)
121
196
  else
122
- Predicate.new(column.to_sym, arel_operator, value)
197
+ Predicate.new(column.to_sym, method, value)
123
198
  end
124
199
  end
125
200
 
126
- # `eq` against a (possibly comma-separated) value becomes an IN set. `matches`
127
- # / `does_not_match` wrap the value in `%...%` with LIKE wildcards escaped so
128
- # they match literally. `null` => nil for every operator.
201
+ # `eq` fans out to an IN lookup (normalize_value turns its value into a set)
202
+ # EXCEPT against NULL: Arel renders `in(nil)` as `IN (NULL)`, which matches no
203
+ # rows in SQL, so eq/in with a nil value stay/become `eq` (rendered `IS NULL`).
204
+ def self.arel_operator(operator, value)
205
+ return operator unless %w[eq in].include?(operator)
206
+
207
+ value.nil? ? "eq" : "in"
208
+ end
209
+
210
+ # `eq` / `in` against a (possibly comma-separated string or Array) value
211
+ # becomes an IN set. `matches` / `does_not_match` wrap the value in `%...%`
212
+ # with LIKE wildcards escaped so they match literally. `null` / a JSON null
213
+ # => nil for the NULL_ACCEPTING_OPERATORS, rejected for every other operator.
129
214
  def self.normalize_value(operator, raw, config:)
130
- return nil if raw.to_s == NULL_TOKEN
215
+ if raw.nil? || raw.to_s == NULL_TOKEN
216
+ unless NULL_ACCEPTING_OPERATORS.include?(operator)
217
+ raise McpToolkit::Errors::InvalidParams,
218
+ "'#{operator}' does not accept a null value " \
219
+ "(only #{NULL_ACCEPTING_OPERATORS.join("/")} do)"
220
+ end
221
+ return nil
222
+ end
131
223
 
132
224
  case operator
133
225
  when "eq", "in"
134
- raw.to_s.split(",")
226
+ resolved = equality_value(raw, config:)
227
+ resolved.is_a?(Array) ? resolved : [resolved]
135
228
  when "matches", "does_not_match"
136
229
  "%#{config.sql_sanitizer.sanitize_sql_like(raw.to_s)}%"
137
230
  else
@@ -139,11 +232,63 @@ module McpToolkit::Filtering
139
232
  end
140
233
  end
141
234
 
142
- def self.equality_value(value)
143
- return nil if value.to_s == NULL_TOKEN
235
+ # A bare (non-operator) filter value resolved for equality: `"null"` / nil =>
236
+ # nil (matches NULL rows); a comma-separated string => its parts (IN); an
237
+ # Array => an IN set of non-null scalars (see .equality_value_set). Any other
238
+ # non-scalar (an op-less Hash) is rejected — passed through it would reach the
239
+ # database as a malformed condition.
240
+ def self.equality_value(value, config: McpToolkit.config)
241
+ return equality_value_set(value, config:) if value.is_a?(Array)
242
+ return nil if value.nil? || value.to_s == NULL_TOKEN
243
+
244
+ if value.is_a?(Hash)
245
+ raise McpToolkit::Errors::InvalidParams,
246
+ "unsupported filter value; use a bare scalar, an array of scalars, " \
247
+ "or { op:, value: } condition(s)"
248
+ end
144
249
 
145
250
  str = value.to_s
146
- str.include?(",") ? str.split(",") : value
251
+ return value unless str.include?(",")
252
+
253
+ parts = str.split(",")
254
+ enforce_filter_limit!(parts.length, config)
255
+ parts
256
+ end
257
+
258
+ # Resolves an Array filter value into a flat IN set. Elements must be non-null
259
+ # scalars (comma-separated strings are split in); nil / nested Array / Hash
260
+ # elements are rejected explicitly — SQL `IN` cannot match NULL (Arel renders
261
+ # a nil element as the never-matching `IN (..., NULL)`), and a non-scalar
262
+ # element is a malformed condition either way. The `"null"` token is NOT
263
+ # resolved inside a set for the same reason: it stays a literal string, and a
264
+ # null-or-nothing condition is expressed as a scalar filter value instead.
265
+ def self.equality_value_set(values, config: McpToolkit.config)
266
+ resolved = values.flat_map do |element|
267
+ if element.nil? || element.is_a?(Array) || element.is_a?(Hash)
268
+ raise McpToolkit::Errors::InvalidParams,
269
+ "an IN-set filter must contain only non-null scalar values; " \
270
+ "to match NULL rows pass \"null\" as the filter's single value"
271
+ end
272
+
273
+ str = element.to_s
274
+ str.include?(",") ? str.split(",") : [element]
275
+ end
276
+ enforce_filter_limit!(resolved.length, config)
277
+ resolved
278
+ end
279
+
280
+ # Bounds how many values an IN-set resolves to (and how many operator
281
+ # conditions may be ANDed on one attribute, enforced by .apply), so a valid
282
+ # token can't emit an unbounded IN clause / AND-chain — oversized SQL + Arel
283
+ # AST and expensive query planning, which matters because rate limiting is
284
+ # opt-in (config.rate_limit_max_requests). Disabled when
285
+ # config.max_filter_values is nil.
286
+ def self.enforce_filter_limit!(count, config)
287
+ max = config.max_filter_values
288
+ return if max.nil? || count <= max
289
+
290
+ raise McpToolkit::Errors::InvalidParams,
291
+ "a filter may carry at most #{max} values or conditions (got #{count})"
147
292
  end
148
293
 
149
294
  # Portable representation of an operator predicate, applied by the in-memory
@@ -96,20 +96,40 @@ class McpToolkit::Gateway::Aggregator
96
96
  # Degrade gracefully: omit this upstream's tools, don't cache the failure.
97
97
  config.logger&.error("MCP upstream #{upstream.key} tools/list failed, omitting: #{e.message}")
98
98
  []
99
+ rescue StandardError => e
100
+ # Backstop: any OTHER error while processing ONE upstream (e.g. a malformed
101
+ # tool definition) must degrade just that upstream, never 500 the whole
102
+ # aggregated tools/list and take every sibling upstream down with it.
103
+ config.logger&.error("MCP upstream #{upstream.key} tools/list errored, omitting: #{e.class}: #{e.message}")
104
+ []
99
105
  end
100
106
 
101
107
  def live_definitions(upstream, bearer_token:)
102
108
  client = McpToolkit::Gateway::Client.new(upstream:, bearer_token:, config:)
103
- client.tools_list.map do |definition|
109
+ client.tools_list.filter_map do |definition|
104
110
  namespaced(upstream, definition)
105
111
  end
106
112
  end
107
113
 
108
- # Re-keys an upstream tool definition into the gateway's aggregate namespace.
114
+ # Re-keys an upstream tool definition into the gateway's aggregate namespace,
115
+ # rewriting backticked generic-tool references in its prose too — a proxied
116
+ # `list` saying "use the `resources` tool" must point at `<app>__resources`,
117
+ # the only name that exists on THIS server's tools/list. The rewrite returns a
118
+ # fresh structure, so the upstream's (possibly cached) definition is never
119
+ # mutated.
109
120
  def namespaced(upstream, definition)
110
- definition = definition.dup
111
- definition["name"] = upstream.name_for(definition["name"])
112
- definition
121
+ # A hostile/broken upstream can return a non-Hash entry (or a Hash with no
122
+ # "name"); skip it (logging the shape, never its content) so one bad entry
123
+ # degrades to omission instead of raising through the whole aggregate.
124
+ unless definition.is_a?(Hash) && definition["name"]
125
+ config.logger&.warn("MCP upstream #{upstream.key}: skipping a malformed tool entry (#{definition.class})")
126
+ return nil
127
+ end
128
+
129
+ rewritten = McpToolkit::ToolReferenceRewriter.rewrite(definition, upstream.name_for(""))
130
+ rewritten = rewritten.dup if rewritten.equal?(definition)
131
+ rewritten["name"] = upstream.name_for(definition["name"])
132
+ rewritten
113
133
  end
114
134
 
115
135
  def cache