Module: McpToolkit::Filtering

Defined in:
lib/mcp_toolkit/filtering.rb

Overview

Applies the list tool's filter params to an already-scoped relation, with the following semantics:

* a BARE value filters by equality:               filter: { booking_id: 42 }
(a comma-separated string becomes an IN lookup:  filter: { status: "a,b" })
* an { op:, value: } HASH filters with an operator: filter: { price: { op: "gteq", value: 100 } }
* an ARRAY of those hashes ANDs them (ranges):
  filter: { price: [{ op: "gteq", value: 100 }, { op: "lt", value: 200 }] }

Supported operators (validated against the column's DB type):

eq, not_eq, gt, gteq, lt, lteq        — numeric / datetime columns
eq, not_eq, in, matches, does_not_match — string columns (matches => case-insensitive LIKE)
eq, not_eq                            — boolean columns

Allowlist-safe: only the resource's declared filterable keys may be filtered, each resolved to its backing column. Unknown keys are rejected upstream by the ListExecutor, and an operator unsupported for a column's type raises InvalidParams — there is no arbitrary column or SQL injection surface.

Defined Under Namespace

Classes: Predicate

Constant Summary collapse

OPERATORS_BY_TYPE =
{
  integer: %w[eq not_eq gt gteq lt lteq].freeze,
  float: %w[eq not_eq gt gteq lt lteq].freeze,
  decimal: %w[eq not_eq gt gteq lt lteq].freeze,
  datetime: %w[eq not_eq gt gteq lt lteq].freeze,
  date: %w[eq not_eq gt gteq lt lteq].freeze,
  string: %w[eq not_eq in matches does_not_match].freeze,
  text: %w[eq not_eq in matches does_not_match].freeze,
  boolean: %w[eq not_eq].freeze
}.freeze
AREL_PREDICATIONS =

Operators that map straight onto an Arel predication method.

%w[eq not_eq gt gteq lt lteq in matches does_not_match].freeze
NULL_TOKEN =
"null"

Class Method Summary collapse

Class Method Details

.apply(relation, column, value, config: McpToolkit.config) ⇒ Object

Returns the relation with the filter(s) applied.

Parameters:

  • relation

    the already account-scoped relation

  • column (Symbol)

    the backing DB column (already resolved from the allowlist)

  • value

    the filter value: a bare value, an { op:, value: } hash, or an array of such hashes

  • config (McpToolkit::Configuration) (defaults to: McpToolkit.config)

    supplies the SQL sanitizer used to escape LIKE wildcards

Returns:

  • the relation with the filter(s) applied



45
46
47
48
49
50
51
52
53
54
55
# File 'lib/mcp_toolkit/filtering.rb', line 45

def self.apply(relation, column, value, config: McpToolkit.config)
  if compound?(value)
    apply_condition(relation, column, value, config:)
  elsif collection?(value)
    value.inject(relation) { |rel, condition| apply_condition(rel, column, condition, config:) }
  else
    # Bare value: equality. A comma-separated string becomes an IN lookup,
    # matching the implicit `eq` semantics.
    relation.where(column => equality_value(value))
  end
end

.apply_condition(relation, column, condition, config:) ⇒ Object



71
72
73
74
75
76
77
78
79
80
# File 'lib/mcp_toolkit/filtering.rb', line 71

def self.apply_condition(relation, column, condition, config:)
  operator = fetch(condition, :op).to_s
  raise McpToolkit::Errors::InvalidParams, "a filter operator is required" if operator.empty?

  type = column_type(relation, column)
  validate_operator!(operator, type, column)

  raw = fetch(condition, :value)
  relation.where(predicate_for(relation, column, operator, raw, config:))
end

.collection?(value) ⇒ Boolean

Several operator-based conditions on one attribute, ANDed (ranges).

Returns:

  • (Boolean)


63
64
65
# File 'lib/mcp_toolkit/filtering.rb', line 63

def self.collection?(value)
  value.is_a?(Array) && value.any? && value.all? { |element| compound?(element) }
end

.column_type(relation, column) ⇒ Object



90
91
92
93
94
95
# File 'lib/mcp_toolkit/filtering.rb', line 90

def self.column_type(relation, column)
  model = relation.respond_to?(:model) ? relation.model : nil
  return nil unless model.respond_to?(:columns_hash)

  model.columns_hash[column.to_s]&.type
end

.compound?(value) ⇒ Boolean

A single operator-based condition, e.g. { op: "gt", value: 1000 }.

Returns:

  • (Boolean)


58
59
60
# File 'lib/mcp_toolkit/filtering.rb', line 58

def self.compound?(value)
  condition_hash?(value) && (value.key?(:op) || value.key?("op"))
end

.condition_hash?(value) ⇒ Boolean

Returns:

  • (Boolean)


67
68
69
# File 'lib/mcp_toolkit/filtering.rb', line 67

def self.condition_hash?(value)
  value.is_a?(Hash)
end

.equality_value(value) ⇒ Object



142
143
144
145
146
147
# File 'lib/mcp_toolkit/filtering.rb', line 142

def self.equality_value(value)
  return nil if value.to_s == NULL_TOKEN

  str = value.to_s
  str.include?(",") ? str.split(",") : value
end

.fetch(condition, key) ⇒ Object

Reads a key regardless of symbol/string keys; checks presence (not truthiness) so a literal false / nil value survives.



84
85
86
87
88
# File 'lib/mcp_toolkit/filtering.rb', line 84

def self.fetch(condition, key)
  return condition[key] if condition.key?(key)

  condition[key.to_s] if condition.key?(key.to_s)
end

.normalize_value(operator, raw, config:) ⇒ Object

eq against a (possibly comma-separated) value becomes an IN set. matches / does_not_match wrap the value in %...% with LIKE wildcards escaped so they match literally. null => nil for every operator.



129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/mcp_toolkit/filtering.rb', line 129

def self.normalize_value(operator, raw, config:)
  return nil if raw.to_s == NULL_TOKEN

  case operator
  when "eq", "in"
    raw.to_s.split(",")
  when "matches", "does_not_match"
    "%#{config.sql_sanitizer.sanitize_sql_like(raw.to_s)}%"
  else
    raw
  end
end

.predicate_for(relation, column, operator, raw, config:) ⇒ Object

Builds the predicate to hand to relation.where. For a real ActiveRecord relation we build an Arel node (so it composes with the scope safely and is immune to SQL injection); a relation without an arel_table (the in-memory test fake) receives a portable Predicate value object it knows how to apply.



114
115
116
117
118
119
120
121
122
123
124
# File 'lib/mcp_toolkit/filtering.rb', line 114

def self.predicate_for(relation, column, operator, raw, config:)
  value = normalize_value(operator, raw, config:)
  arel_operator = operator == "eq" ? "in" : operator

  model = relation.respond_to?(:model) ? relation.model : nil
  if model.respond_to?(:arel_table)
    model.arel_table[column.to_sym].public_send(arel_operator, value)
  else
    Predicate.new(column.to_sym, arel_operator, value)
  end
end

.validate_operator!(operator, type, column) ⇒ Object



97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/mcp_toolkit/filtering.rb', line 97

def self.validate_operator!(operator, type, column)
  allowed = OPERATORS_BY_TYPE[type]
  if allowed.nil?
    raise McpToolkit::Errors::InvalidParams,
          "'#{column}' cannot be filtered with operators"
  end
  return if allowed.include?(operator)

  raise McpToolkit::Errors::InvalidParams,
        "'#{operator}' operator is not supported for #{column} (#{type}). " \
        "Supported operators: #{allowed.join(", ")}."
end