mcp_toolkit 0.4.0 → 0.6.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +418 -0
  3. data/README.md +174 -0
  4. data/app/views/mcp_toolkit/oauth/authorize.html.erb +83 -0
  5. data/config/routes.rb +22 -1
  6. data/lib/mcp_toolkit/authority/controller_methods.rb +39 -0
  7. data/lib/mcp_toolkit/authority/registry_tool_provider.rb +9 -10
  8. data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
  9. data/lib/mcp_toolkit/authority/tools/base.rb +31 -3
  10. data/lib/mcp_toolkit/authority/tools/get.rb +2 -1
  11. data/lib/mcp_toolkit/authority/tools/list.rb +49 -1
  12. data/lib/mcp_toolkit/authority/tools/resource_schema.rb +10 -2
  13. data/lib/mcp_toolkit/authority/tools/resources.rb +26 -4
  14. data/lib/mcp_toolkit/configuration.rb +526 -7
  15. data/lib/mcp_toolkit/dispatcher.rb +17 -3
  16. data/lib/mcp_toolkit/engine.rb +20 -2
  17. data/lib/mcp_toolkit/engine_controllers.rb +80 -5
  18. data/lib/mcp_toolkit/filtering.rb +168 -23
  19. data/lib/mcp_toolkit/gateway/aggregator.rb +25 -5
  20. data/lib/mcp_toolkit/list_executor.rb +48 -4
  21. data/lib/mcp_toolkit/oauth/controller_methods.rb +426 -0
  22. data/lib/mcp_toolkit/oauth.rb +23 -0
  23. data/lib/mcp_toolkit/resource.rb +55 -6
  24. data/lib/mcp_toolkit/resource_schema.rb +125 -16
  25. data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
  26. data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
  27. data/lib/mcp_toolkit/session.rb +2 -2
  28. data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
  29. data/lib/mcp_toolkit/tools/authority_base.rb +23 -2
  30. data/lib/mcp_toolkit/tools/base.rb +23 -3
  31. data/lib/mcp_toolkit/tools/get.rb +1 -1
  32. data/lib/mcp_toolkit/tools/list.rb +27 -9
  33. data/lib/mcp_toolkit/tools/resource_schema.rb +12 -3
  34. data/lib/mcp_toolkit/tools/resources.rb +30 -7
  35. data/lib/mcp_toolkit/usage_metering/recorder.rb +28 -2
  36. data/lib/mcp_toolkit/version.rb +1 -1
  37. data/lib/mcp_toolkit.rb +8 -2
  38. metadata +8 -1
@@ -28,7 +28,7 @@ module McpToolkit
28
28
  # The controllers built directly under McpToolkit (the engine's routes point at
29
29
  # these). McpToolkit::Authority::ServerController is built alongside them but is
30
30
  # fetched through McpToolkit::Authority's own const_missing.
31
- ENGINE_CONTROLLER_NAMES = %i[ServerController TokensController].freeze
31
+ ENGINE_CONTROLLER_NAMES = %i[ServerController TokensController OauthController].freeze
32
32
 
33
33
  # (Re)builds the engine controllers + the authority base from the current
34
34
  # config. Idempotent: an existing constant is replaced so a rebuild reflects a
@@ -37,6 +37,11 @@ module McpToolkit
37
37
  parent = config.parent_controller.constantize
38
38
  define_controller(self, :ServerController, build_server_controller(parent))
39
39
  define_controller(self, :TokensController, build_tokens_controller(parent))
40
+ # Only when the bridge is on — matching its routes, which are equally gated.
41
+ # Its parent (default ActionController::Base) would otherwise be constantized
42
+ # on every host, pulling view machinery into an API-only app that never
43
+ # enables the bridge, and breaking a non-Rails host outright.
44
+ define_controller(self, :OauthController, build_oauth_controller) if config.oauth_bridge?
40
45
  define_controller(Authority, :ServerController, build_authority_server_controller(parent))
41
46
  ServerController
42
47
  end
@@ -44,15 +49,23 @@ module McpToolkit
44
49
  # Undefines the built controllers so the next reference rebuilds them from the
45
50
  # then-current config. Called from the engine's `to_prepare` on every reload.
46
51
  def self.reset_engine_controllers!
47
- [[self, :ServerController], [self, :TokensController], [Authority, :ServerController]].each do |mod, name|
52
+ ENGINE_CONTROLLER_NAMES.map { |name| [self, name] }.push([Authority, :ServerController]).each do |mod, name|
48
53
  mod.send(:remove_const, name) if mod.const_defined?(name, false)
49
54
  end
50
55
  end
51
56
 
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.
57
+ # The transport controller the engine mounts at POST/GET/DELETE /mcp, chosen by
58
+ # ROLE so a single `mount McpToolkit::Engine => "/mcp"` works for either kind of
59
+ # host: an AUTHORITY (`auth_role == :authority`) gets the hand-rolled dispatcher
60
+ # path (Authority::ControllerMethods — local token auth, gateway proxying, usage
61
+ # metering, rate limiting), a SATELLITE (the default) gets the SDK-backed path
62
+ # (Transport::ControllerMethods, which forwards tokens to a central app). Built
63
+ # lazily so both `config.parent_controller` and `config.auth_role` are read after
64
+ # the host's initializer/to_prepare has run. A host that would rather draw its own
65
+ # routes still subclasses McpToolkit::Authority::ServerController directly.
54
66
  def self.build_server_controller(parent)
55
- Class.new(parent) { include McpToolkit::Transport::ControllerMethods }
67
+ concern = config.authority? ? McpToolkit::Authority::ControllerMethods : McpToolkit::Transport::ControllerMethods
68
+ Class.new(parent) { include concern }
56
69
  end
57
70
 
58
71
  # The AUTHORITY introspection endpoint the engine mounts at
@@ -88,12 +101,74 @@ module McpToolkit
88
101
  end
89
102
  end
90
103
 
104
+ # The OAuth authorization bridge the engine mounts at `<mcp>/oauth/*` and the
105
+ # host draws at the two `.well-known` metadata paths.
106
+ #
107
+ # Built from `config.oauth_parent_controller`, NOT the `parent_controller` its
108
+ # siblings use: the transport is a JSON-only endpoint that a host rightly points
109
+ # at ActionController::API, which cannot render an HTML view — and this
110
+ # controller's authorization page is one. Sharing the parent would force a host
111
+ # to weaken its transport's superclass just to enable the bridge. Read lazily
112
+ # here, like the rest, so the host's initializer/to_prepare has already run.
113
+ def self.build_oauth_controller
114
+ warn_about_per_process_cache_store
115
+ Class.new(config.oauth_parent_controller.constantize) { include McpToolkit::Oauth::ControllerMethods }
116
+ end
117
+
118
+ # Said once per boot (this runs from the engine's `to_prepare`), because the
119
+ # failure it predicts is otherwise hard to read as a misconfiguration at all:
120
+ # with a per-process cache the two legs of a flow land on different workers, so
121
+ # the exchange fails (N-1)/N of the time — intermittently, and only AFTER the
122
+ # operator has already pasted a live token. A warning rather than a gate,
123
+ # because one process is genuinely fine and `Rails.cache` is a MemoryStore in a
124
+ # stock development environment.
125
+ def self.warn_about_per_process_cache_store
126
+ return unless config.oauth_per_process_cache_store?
127
+
128
+ config.logger&.warn(
129
+ "[mcp_toolkit] The OAuth bridge is enabled but config.cache_store is an in-process MemoryStore. " \
130
+ "That is safe in a single process only: on a multi-worker deployment an authorization code issued " \
131
+ "by one worker is invisible to the worker that redeems it, so the flow fails intermittently AFTER " \
132
+ "the operator has pasted their token. Point config.cache_store at a shared store (Rails.cache " \
133
+ "backed by Redis/Memcached) before running the bridge in production."
134
+ )
135
+ end
136
+
91
137
  # The AUTHORITY base controller a host subclasses (the recommended path for a
92
138
  # host whose rate-limit/usage/account hooks touch app models).
93
139
  def self.build_authority_server_controller(parent)
94
140
  Class.new(parent) { include McpToolkit::Authority::ControllerMethods }
95
141
  end
96
142
 
143
+ # Draws the OAuth bridge's two metadata documents. A `/.well-known/*` path
144
+ # cannot be drawn by an engine mounted under a path, so it has to live in the
145
+ # host's own route set. The host calls this at the TOP LEVEL — not inside a
146
+ # locale/format/constraint scope, which would prefix the paths out of view:
147
+ #
148
+ # # config/routes.rb
149
+ # Rails.application.routes.draw do
150
+ # McpToolkit.draw_oauth_metadata_routes(self)
151
+ # mount McpToolkit::Engine => "/mcp"
152
+ # # ...
153
+ # end
154
+ #
155
+ # The paths are PATH-SCOPED to the engine's mount
156
+ # (`/.well-known/oauth-protected-resource/mcp`), so this claims NOTHING
157
+ # origin-global and cannot collide with an OAuth provider the host already runs
158
+ # — see Configuration#oauth_protected_resource_path for why that matters. A host
159
+ # mounted at its origin root gets the bare paths instead, which is correct there.
160
+ #
161
+ # A no-op unless the bridge is configured, so the call can sit in a host's routes
162
+ # unconditionally across environments.
163
+ def self.draw_oauth_metadata_routes(mapper)
164
+ return unless config.oauth_bridge?
165
+
166
+ mapper.get config.oauth_protected_resource_path,
167
+ to: "mcp_toolkit/oauth#protected_resource", format: false
168
+ mapper.get config.oauth_authorization_server_path,
169
+ to: "mcp_toolkit/oauth#authorization_server", format: false
170
+ end
171
+
97
172
  # Removes an existing same-named constant (avoiding a redefinition warning on a
98
173
  # rebuild) before setting the freshly-built class.
99
174
  def self.define_controller(mod, name, klass)
@@ -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
@@ -66,10 +66,20 @@ class McpToolkit::ListExecutor
66
66
  relation
67
67
  end
68
68
 
69
- # Order by `id` when the primary key is numeric; otherwise by `created_at`
70
- # (a non-numeric PK does not sort meaningfully).
69
+ # Order by `id` when the primary key is numeric; otherwise per
70
+ # `config.non_numeric_pk_order`: `created_at` with the primary key as a
71
+ # tiebreaker (default — rows bulk-inserted in one transaction share a
72
+ # `created_at`, and without a total order offset pagination could duplicate
73
+ # or skip rows), or the primary key alone for a host preserving a pre-gem
74
+ # order-by-id contract. `numeric_primary_key?` returning false guarantees the
75
+ # model exposes a non-nil primary key, so it can be read directly here.
71
76
  def apply_order(relation)
72
- relation.order(numeric_primary_key? ? :id : :created_at)
77
+ return relation.order(:id) if numeric_primary_key?
78
+
79
+ pk = resource.model.primary_key.to_sym
80
+ return relation.order(pk) if McpToolkit.config.non_numeric_pk_order == :primary_key
81
+
82
+ relation.order(:created_at, pk)
73
83
  end
74
84
 
75
85
  def numeric_primary_key?
@@ -98,9 +108,10 @@ class McpToolkit::ListExecutor
98
108
 
99
109
  mapping = resource.filterable_columns
100
110
  validate_filter_keys!(filter, mapping)
111
+ validate_filter_companions!(filter)
101
112
 
102
113
  filter.each do |request_key, value|
103
- next if value.nil? || value == ""
114
+ next if skipped_filter_value?(value)
104
115
 
105
116
  column = mapping[request_key.to_sym]
106
117
  relation = McpToolkit::Filtering.apply(relation, column, value)
@@ -108,6 +119,35 @@ class McpToolkit::ListExecutor
108
119
  relation
109
120
  end
110
121
 
122
+ # Under :tokenized semantics an empty string means "no filter" (a JSON null
123
+ # still flows through as an IS NULL filter, like the "null" token — see
124
+ # McpToolkit::Filtering); under :literal every value reaches the WHERE clause
125
+ # verbatim.
126
+ def skipped_filter_value?(value)
127
+ value == "" && McpToolkit.config.bare_filter_value_semantics != :literal
128
+ end
129
+
130
+ # A filter key may declare a companion key it cannot be used without (e.g. a
131
+ # polymorphic foreign key is type-ambiguous without its `*_type`) — see
132
+ # Resource#filter_requirements. Rejected up front rather than producing a
133
+ # subtly wrong WHERE. A key whose value the apply loop will SKIP counts as
134
+ # not provided — a skipped ("" under :tokenized) companion must not satisfy
135
+ # the requirement, or the FK would be applied alone: exactly the
136
+ # type-ambiguous WHERE the requirement exists to prevent.
137
+ def validate_filter_companions!(filter)
138
+ requirements = resource.filter_requirements
139
+ return if requirements.empty?
140
+
141
+ provided = filter.reject { |_key, value| skipped_filter_value?(value) }.keys.map(&:to_sym)
142
+ requirements.each do |key, required|
143
+ next unless provided.include?(key)
144
+ next if provided.include?(required)
145
+
146
+ raise McpToolkit::Errors::InvalidParams,
147
+ "filter attribute #{key} requires #{required} to also be provided"
148
+ end
149
+ end
150
+
111
151
  def validate_filter_keys!(filter, mapping)
112
152
  keys = filter.keys.map(&:to_sym)
113
153
  unknown = keys - mapping.keys
@@ -123,6 +163,10 @@ class McpToolkit::ListExecutor
123
163
  return relation if params[:ids].blank?
124
164
 
125
165
  ids = params[:ids].to_s.split(",").map(&:strip).compact_blank
166
+ # Bound the IN-set the same way the per-attribute filters are bounded — the
167
+ # top-level `ids` param builds `WHERE id IN (...)` on its own path, so it
168
+ # must honor config.max_filter_values too (else it's an unbounded IN clause).
169
+ McpToolkit::Filtering.enforce_filter_limit!(ids.length, McpToolkit.config)
126
170
  ids.empty? ? relation : relation.where(id: ids)
127
171
  end
128
172