mcp_toolkit 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +231 -0
- data/config/routes.rb +1 -1
- data/lib/mcp_toolkit/authority/controller_methods.rb +15 -0
- data/lib/mcp_toolkit/authority/registry_tool_provider.rb +9 -10
- data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
- data/lib/mcp_toolkit/authority/tools/base.rb +31 -3
- data/lib/mcp_toolkit/authority/tools/get.rb +2 -1
- data/lib/mcp_toolkit/authority/tools/list.rb +49 -1
- data/lib/mcp_toolkit/authority/tools/resource_schema.rb +10 -2
- data/lib/mcp_toolkit/authority/tools/resources.rb +26 -4
- data/lib/mcp_toolkit/configuration.rb +161 -7
- data/lib/mcp_toolkit/dispatcher.rb +17 -3
- data/lib/mcp_toolkit/engine.rb +5 -2
- data/lib/mcp_toolkit/engine_controllers.rb +11 -3
- data/lib/mcp_toolkit/filtering.rb +168 -23
- data/lib/mcp_toolkit/gateway/aggregator.rb +25 -5
- data/lib/mcp_toolkit/list_executor.rb +48 -4
- data/lib/mcp_toolkit/resource.rb +55 -6
- data/lib/mcp_toolkit/resource_schema.rb +125 -16
- data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
- data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
- data/lib/mcp_toolkit/session.rb +2 -2
- data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
- data/lib/mcp_toolkit/tools/authority_base.rb +23 -2
- data/lib/mcp_toolkit/tools/base.rb +23 -3
- data/lib/mcp_toolkit/tools/get.rb +1 -1
- data/lib/mcp_toolkit/tools/list.rb +27 -9
- data/lib/mcp_toolkit/tools/resource_schema.rb +12 -3
- data/lib/mcp_toolkit/tools/resources.rb +30 -7
- data/lib/mcp_toolkit/usage_metering/recorder.rb +28 -2
- data/lib/mcp_toolkit/version.rb +1 -1
- metadata +5 -1
|
@@ -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.
|
|
281
|
-
# contributes no own tools (a pure gateway).
|
|
368
|
+
# McpToolkit::Tools::AuthorityBase for a base that satisfies this.
|
|
282
369
|
#
|
|
283
|
-
#
|
|
284
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
data/lib/mcp_toolkit/engine.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
53
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
29
|
-
text: %w[eq not_eq
|
|
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.
|
|
52
|
-
#
|
|
53
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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(
|
|
195
|
+
model.arel_table[column.to_sym].public_send(method, value)
|
|
121
196
|
else
|
|
122
|
-
Predicate.new(column.to_sym,
|
|
197
|
+
Predicate.new(column.to_sym, method, value)
|
|
123
198
|
end
|
|
124
199
|
end
|
|
125
200
|
|
|
126
|
-
# `eq`
|
|
127
|
-
#
|
|
128
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
143
|
-
|
|
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?(",")
|
|
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.
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|