mcp_toolkit 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -2
  3. data/README.md +315 -34
  4. data/config/routes.rb +20 -5
  5. data/lib/mcp_toolkit/auth/authority.rb +2 -2
  6. data/lib/mcp_toolkit/authority/composite_tool_provider.rb +32 -0
  7. data/lib/mcp_toolkit/authority/context.rb +45 -0
  8. data/lib/mcp_toolkit/authority/controller_methods.rb +423 -0
  9. data/lib/mcp_toolkit/authority/registry_tool_provider.rb +69 -0
  10. data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
  11. data/lib/mcp_toolkit/authority/token.rb +124 -0
  12. data/lib/mcp_toolkit/authority/tools/base.rb +150 -0
  13. data/lib/mcp_toolkit/authority/tools/get.rb +59 -0
  14. data/lib/mcp_toolkit/authority/tools/list.rb +132 -0
  15. data/lib/mcp_toolkit/authority/tools/resource_schema.rb +52 -0
  16. data/lib/mcp_toolkit/authority/tools/resources.rb +55 -0
  17. data/lib/mcp_toolkit/authority.rb +24 -0
  18. data/lib/mcp_toolkit/configuration.rb +362 -5
  19. data/lib/mcp_toolkit/dispatcher.rb +248 -0
  20. data/lib/mcp_toolkit/engine.rb +26 -8
  21. data/lib/mcp_toolkit/engine_controllers.rb +124 -0
  22. data/lib/mcp_toolkit/filtering.rb +168 -23
  23. data/lib/mcp_toolkit/gateway/aggregator.rb +142 -0
  24. data/lib/mcp_toolkit/gateway/client.rb +305 -0
  25. data/lib/mcp_toolkit/gateway/proxy.rb +70 -0
  26. data/lib/mcp_toolkit/gateway/unknown_upstream.rb +8 -0
  27. data/lib/mcp_toolkit/gateway/upstream_call_error.rb +23 -0
  28. data/lib/mcp_toolkit/gateway/upstream_registry.rb +79 -0
  29. data/lib/mcp_toolkit/list_executor.rb +65 -4
  30. data/lib/mcp_toolkit/protocol.rb +106 -0
  31. data/lib/mcp_toolkit/rate_limiter.rb +73 -0
  32. data/lib/mcp_toolkit/registry.rb +30 -2
  33. data/lib/mcp_toolkit/resource.rb +173 -6
  34. data/lib/mcp_toolkit/resource_schema.rb +135 -13
  35. data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
  36. data/lib/mcp_toolkit/serializer/base.rb +2 -2
  37. data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
  38. data/lib/mcp_toolkit/session.rb +18 -10
  39. data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
  40. data/lib/mcp_toolkit/tools/authority_base.rb +148 -0
  41. data/lib/mcp_toolkit/tools/base.rb +23 -3
  42. data/lib/mcp_toolkit/tools/get.rb +1 -1
  43. data/lib/mcp_toolkit/tools/list.rb +27 -9
  44. data/lib/mcp_toolkit/tools/resource_schema.rb +12 -3
  45. data/lib/mcp_toolkit/tools/resources.rb +30 -7
  46. data/lib/mcp_toolkit/transport/controller_methods.rb +2 -2
  47. data/lib/mcp_toolkit/usage_metering/recorder.rb +121 -0
  48. data/lib/mcp_toolkit/version.rb +1 -1
  49. data/lib/mcp_toolkit.rb +16 -3
  50. metadata +42 -2
  51. data/app/controllers/mcp_toolkit/server_controller.rb +0 -19
@@ -1,21 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Mountable Rails engine that draws the four MCP transport routes (defined in the
4
- # engine's config/routes.rb so they survive Rails' route reloads) against the
5
- # gem-provided McpToolkit::ServerController. A satellite mounts it in one line:
3
+ # Mountable Rails engine that draws the MCP transport routes plus the authority
4
+ # introspection route (defined in the engine's config/routes.rb so they survive
5
+ # Rails' route reloads) against the gem-provided McpToolkit::ServerController /
6
+ # McpToolkit::TokensController. A satellite OR an authority mounts it in one line:
6
7
  #
7
8
  # # config/routes.rb
8
9
  # mount McpToolkit::Engine => "/mcp"
9
10
  #
10
- # 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:
11
15
  #
12
- # POST /mcp -> create (JSON-RPC requests/responses)
13
- # GET /mcp -> stream (405; no server-initiated SSE)
14
- # DELETE /mcp -> destroy (terminate the session)
15
- # GET /mcp/health -> health (unauthenticated probe)
16
+ # POST /mcp -> create (JSON-RPC requests/responses)
17
+ # GET /mcp -> stream (405; no server-initiated SSE)
18
+ # DELETE /mcp -> destroy (terminate the session)
19
+ # GET /mcp/health -> health (unauthenticated probe)
20
+ # POST /mcp/tokens/introspect -> introspect (authority token introspection;
21
+ # drawn ONLY when auth_role is
22
+ # :authority — a satellite that
23
+ # mounts the engine gets no such
24
+ # route)
16
25
  #
17
26
  # Loaded ONLY when Rails::Engine is available (see lib/mcp_toolkit.rb); the gem's
18
27
  # non-Rails consumers and its own unit suite never reference it.
19
28
  class McpToolkit::Engine < Rails::Engine
20
29
  isolate_namespace McpToolkit
30
+
31
+ # The gem-provided controllers subclass `config.parent_controller`, which the
32
+ # host sets in an initializer/to_prepare that must be READ AFTER it runs
33
+ # (Constraint B). They are therefore built lazily by
34
+ # `McpToolkit.build_engine_controllers!` (triggered via const_missing on first
35
+ # reference — at eager-load or first request). This resets them on every code
36
+ # reload so a changed parent (or a reloaded app parent class) takes effect on the
37
+ # next reference. Runs before `:eager_load!`, so the fresh classes exist for it.
38
+ config.to_prepare { McpToolkit.reset_engine_controllers! }
21
39
  end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Lazy `parent_controller` builder (Constraint B).
4
+ #
5
+ # The engine's controllers (McpToolkit::ServerController,
6
+ # McpToolkit::TokensController) and the authority base
7
+ # (McpToolkit::Authority::ServerController) all subclass
8
+ # `config.parent_controller`. If that superclass were resolved in a class body of
9
+ # an autoloaded/eager-loaded file, it could be read BEFORE the host's
10
+ # initializer/to_prepare had set it — defaulting to ActionController::Base and, in
11
+ # turn, breaking CSRF handling on the introspection endpoint.
12
+ #
13
+ # Instead, none of these controllers is a Zeitwerk-managed file. They are built
14
+ # here from the CURRENT config, and the build is triggered LAZILY:
15
+ # * `const_missing` (below, and on McpToolkit::Authority) builds them the first
16
+ # time they are referenced — which, in a host, is at eager-load or first-
17
+ # request time, i.e. AFTER the app's initializers/to_prepare have run;
18
+ # * the engine's `config.to_prepare` RESETS them on every code reload so a
19
+ # changed `parent_controller` (or a reloaded app parent class) takes effect on
20
+ # the next reference.
21
+ #
22
+ # The whole `config/initializers/mcp_toolkit.rb` of a host can therefore live in
23
+ # `to_prepare`: the parent is only ever read at build time.
24
+ #
25
+ # This file reopens `McpToolkit` to add module methods, so it is Zeitwerk-IGNORED
26
+ # (like engine.rb) and required explicitly from the gem entry point.
27
+ module McpToolkit
28
+ # The controllers built directly under McpToolkit (the engine's routes point at
29
+ # these). McpToolkit::Authority::ServerController is built alongside them but is
30
+ # fetched through McpToolkit::Authority's own const_missing.
31
+ ENGINE_CONTROLLER_NAMES = %i[ServerController TokensController].freeze
32
+
33
+ # (Re)builds the engine controllers + the authority base from the current
34
+ # config. Idempotent: an existing constant is replaced so a rebuild reflects a
35
+ # changed `parent_controller`. Reads `config.parent_controller` at call time.
36
+ def self.build_engine_controllers!
37
+ parent = config.parent_controller.constantize
38
+ define_controller(self, :ServerController, build_server_controller(parent))
39
+ define_controller(self, :TokensController, build_tokens_controller(parent))
40
+ define_controller(Authority, :ServerController, build_authority_server_controller(parent))
41
+ ServerController
42
+ end
43
+
44
+ # Undefines the built controllers so the next reference rebuilds them from the
45
+ # then-current config. Called from the engine's `to_prepare` on every reload.
46
+ def self.reset_engine_controllers!
47
+ [[self, :ServerController], [self, :TokensController], [Authority, :ServerController]].each do |mod, name|
48
+ mod.send(:remove_const, name) if mod.const_defined?(name, false)
49
+ end
50
+ end
51
+
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.
61
+ def self.build_server_controller(parent)
62
+ concern = config.authority? ? McpToolkit::Authority::ControllerMethods : McpToolkit::Transport::ControllerMethods
63
+ Class.new(parent) { include concern }
64
+ end
65
+
66
+ # The AUTHORITY introspection endpoint the engine mounts at
67
+ # `POST /mcp/tokens/introspect`. Behavior is preserved exactly: it authenticates
68
+ # the bearer against `config.token_authenticator` (via Auth::Authority) and
69
+ # renders the introspection payload; a non-authority app answers `{ valid:
70
+ # false }` rather than erroring.
71
+ def self.build_tokens_controller(parent)
72
+ Class.new(parent) do
73
+ def introspect
74
+ token = McpToolkit::Auth::Authority.authenticate(mcp_extract_token, config: mcp_config)
75
+ return render(json: McpToolkit::Auth::Authority.invalid_payload, status: :unauthorized) if token.nil?
76
+
77
+ render json: McpToolkit::Auth::Authority.introspection_payload(token)
78
+ rescue McpToolkit::Errors::ConfigurationError
79
+ # Not configured as an authority (no token_authenticator): behave as if the
80
+ # token were invalid instead of surfacing a 500.
81
+ render json: McpToolkit::Auth::Authority.invalid_payload, status: :unauthorized
82
+ end
83
+
84
+ private
85
+
86
+ def mcp_config
87
+ McpToolkit.config
88
+ end
89
+
90
+ def mcp_extract_token
91
+ auth_header = request.headers["Authorization"]
92
+ return auth_header.sub("Bearer ", "") if auth_header&.start_with?("Bearer ")
93
+
94
+ request.headers["X-MCP-Token"].presence || params[:token].presence
95
+ end
96
+ end
97
+ end
98
+
99
+ # The AUTHORITY base controller a host subclasses (the recommended path for a
100
+ # host whose rate-limit/usage/account hooks touch app models).
101
+ def self.build_authority_server_controller(parent)
102
+ Class.new(parent) { include McpToolkit::Authority::ControllerMethods }
103
+ end
104
+
105
+ # Removes an existing same-named constant (avoiding a redefinition warning on a
106
+ # rebuild) before setting the freshly-built class.
107
+ def self.define_controller(mod, name, klass)
108
+ mod.send(:remove_const, name) if mod.const_defined?(name, false)
109
+ mod.const_set(name, klass)
110
+ end
111
+
112
+ # Backstop: build the engine controllers the first time one is referenced before
113
+ # any `to_prepare`/eager-load pass has built them (e.g. a bespoke route that
114
+ # names McpToolkit::ServerController directly). McpToolkit::Authority defines the
115
+ # sibling backstop for its ServerController.
116
+ def self.const_missing(name)
117
+ if ENGINE_CONTROLLER_NAMES.include?(name)
118
+ build_engine_controllers!
119
+ return const_get(name) if const_defined?(name, false)
120
+ end
121
+
122
+ super
123
+ end
124
+ end
@@ -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
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ # Aggregates the tool lists of all configured upstream MCP servers for a
6
+ # gateway's `tools/list`, namespacing each tool as `<app>__<tool>`.
7
+ #
8
+ # Per upstream the namespaced list is cached in `config.cache_store`
9
+ # (`config.upstream_list_ttl`, default 15 min) and bustable via `flush!` /
10
+ # `flush!(key)`. On a cache miss the live pull runs per-upstream (one HTTP call
11
+ # each); a failing/timeout upstream is omitted + logged, never breaking the list.
12
+ #
13
+ # Why the cache is safe to share globally: an upstream's `tools/list` is
14
+ # token-INDEPENDENT — it returns the same public tool definitions to every valid
15
+ # caller and enforces scope only when a tool is CALLED (per-call authorization is
16
+ # the upstream's job). So one caller's pull is a correct answer for all callers.
17
+ # This is a registration CONTRACT, not an assumption: an upstream that filters its
18
+ # list by the caller's privilege (e.g. hides superuser-only tools) MUST register
19
+ # `public_tool_list: false`, which opts it out of this cache (pulled live per
20
+ # request) so a privileged caller's list can never be served to an unprivileged
21
+ # one. See McpToolkit::Gateway::UpstreamRegistry::Upstream.
22
+ #
23
+ # Only a NON-EMPTY pull is ever cached. An empty or failed pull is almost always a
24
+ # transient upstream hiccup (timeout, a session/handshake blip, a degenerate 200);
25
+ # caching it would freeze a whole app's tools out of `tools/list` for the full TTL
26
+ # for EVERY caller (a poisoned global cache), which is exactly the failure this
27
+ # guards against. A stale empty already in the cache is treated as a miss and
28
+ # re-pulled, so the aggregate self-heals as soon as the upstream returns tools.
29
+ #
30
+ # Concurrency: upstreams are pulled CONCURRENTLY via concurrent-ruby futures. When
31
+ # running inside Rails, each future is wrapped in `Rails.application.executor` so
32
+ # it participates in the framework's per-request lifecycle (reloading, query
33
+ # cache, connection checkout); a non-Rails host runs plain futures. Output order
34
+ # follows the registry order.
35
+ class McpToolkit::Gateway::Aggregator
36
+ CACHE_KEY_PREFIX = "mcp_toolkit:gateway:tools:"
37
+
38
+ def initialize(config: McpToolkit.config)
39
+ @config = config
40
+ end
41
+
42
+ # Namespaced tool definitions across all configured upstreams. `bearer_token`
43
+ # is used only on a cache miss, so the upstream can authenticate the list
44
+ # request the same way it would a call. Upstreams are fetched concurrently;
45
+ # output order follows the registry order.
46
+ def tool_definitions(bearer_token: nil)
47
+ futures = config.upstreams.all.map do |upstream|
48
+ Concurrent::Promises.future do
49
+ within_executor { cached_or_live_definitions(upstream, bearer_token:) }
50
+ end
51
+ end
52
+
53
+ futures.flat_map(&:value!)
54
+ end
55
+
56
+ def flush!(key = nil)
57
+ if key
58
+ cache.delete(cache_key(key))
59
+ else
60
+ config.upstreams.all.each { |upstream| cache.delete(cache_key(upstream.key)) }
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ attr_reader :config
67
+
68
+ # Runs the block inside the Rails executor when a BOOTED Rails app is present
69
+ # (so a future participates in the request lifecycle: reloading, query cache,
70
+ # connection checkout), or plainly otherwise. Guards for `Rails` being defined
71
+ # but not booted (e.g. `rails/version` required without an initialized app), in
72
+ # which case `Rails.application` is nil and we run the block directly.
73
+ def within_executor(&)
74
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
75
+ Rails.application.executor.wrap(&)
76
+ else
77
+ yield
78
+ end
79
+ end
80
+
81
+ def cached_or_live_definitions(upstream, bearer_token:)
82
+ # A caller-dependent upstream opts out of the shared cache (keyed by upstream
83
+ # only): caching it would leak one caller's list to callers of other privilege.
84
+ return live_definitions(upstream, bearer_token:) unless upstream.public_tool_list
85
+
86
+ cached = cache.read(cache_key(upstream.key))
87
+ # `present?` treats both a nil miss AND a stale empty list as "no cache", so a
88
+ # previously poisoned empty entry is re-pulled instead of served.
89
+ return cached if cached.present?
90
+
91
+ definitions = live_definitions(upstream, bearer_token:)
92
+ # Only persist a real, non-empty list; never cache an empty/degraded pull.
93
+ cache.write(cache_key(upstream.key), definitions, expires_in: config.upstream_list_ttl) if definitions.present?
94
+ definitions
95
+ rescue McpToolkit::Gateway::Client::Error => e
96
+ # Degrade gracefully: omit this upstream's tools, don't cache the failure.
97
+ config.logger&.error("MCP upstream #{upstream.key} tools/list failed, omitting: #{e.message}")
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
+ []
105
+ end
106
+
107
+ def live_definitions(upstream, bearer_token:)
108
+ client = McpToolkit::Gateway::Client.new(upstream:, bearer_token:, config:)
109
+ client.tools_list.filter_map do |definition|
110
+ namespaced(upstream, definition)
111
+ end
112
+ end
113
+
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.
120
+ def namespaced(upstream, 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
133
+ end
134
+
135
+ def cache
136
+ config.cache_store
137
+ end
138
+
139
+ def cache_key(key)
140
+ "#{CACHE_KEY_PREFIX}#{key}"
141
+ end
142
+ end