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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +418 -0
- data/README.md +174 -0
- data/app/views/mcp_toolkit/oauth/authorize.html.erb +83 -0
- data/config/routes.rb +22 -1
- data/lib/mcp_toolkit/authority/controller_methods.rb +39 -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 +526 -7
- data/lib/mcp_toolkit/dispatcher.rb +17 -3
- data/lib/mcp_toolkit/engine.rb +20 -2
- data/lib/mcp_toolkit/engine_controllers.rb +80 -5
- 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/oauth/controller_methods.rb +426 -0
- data/lib/mcp_toolkit/oauth.rb +23 -0
- 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
- data/lib/mcp_toolkit.rb +8 -2
- 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
|
-
|
|
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
|
|
53
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
@@ -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
|
|
70
|
-
#
|
|
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(
|
|
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
|
|
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
|
|