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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +432 -2
- data/README.md +315 -34
- data/config/routes.rb +20 -5
- data/lib/mcp_toolkit/auth/authority.rb +2 -2
- data/lib/mcp_toolkit/authority/composite_tool_provider.rb +32 -0
- data/lib/mcp_toolkit/authority/context.rb +45 -0
- data/lib/mcp_toolkit/authority/controller_methods.rb +423 -0
- data/lib/mcp_toolkit/authority/registry_tool_provider.rb +69 -0
- data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
- data/lib/mcp_toolkit/authority/token.rb +124 -0
- data/lib/mcp_toolkit/authority/tools/base.rb +150 -0
- data/lib/mcp_toolkit/authority/tools/get.rb +59 -0
- data/lib/mcp_toolkit/authority/tools/list.rb +132 -0
- data/lib/mcp_toolkit/authority/tools/resource_schema.rb +52 -0
- data/lib/mcp_toolkit/authority/tools/resources.rb +55 -0
- data/lib/mcp_toolkit/authority.rb +24 -0
- data/lib/mcp_toolkit/configuration.rb +362 -5
- data/lib/mcp_toolkit/dispatcher.rb +248 -0
- data/lib/mcp_toolkit/engine.rb +26 -8
- data/lib/mcp_toolkit/engine_controllers.rb +124 -0
- data/lib/mcp_toolkit/filtering.rb +168 -23
- data/lib/mcp_toolkit/gateway/aggregator.rb +142 -0
- data/lib/mcp_toolkit/gateway/client.rb +305 -0
- data/lib/mcp_toolkit/gateway/proxy.rb +70 -0
- data/lib/mcp_toolkit/gateway/unknown_upstream.rb +8 -0
- data/lib/mcp_toolkit/gateway/upstream_call_error.rb +23 -0
- data/lib/mcp_toolkit/gateway/upstream_registry.rb +79 -0
- data/lib/mcp_toolkit/list_executor.rb +65 -4
- data/lib/mcp_toolkit/protocol.rb +106 -0
- data/lib/mcp_toolkit/rate_limiter.rb +73 -0
- data/lib/mcp_toolkit/registry.rb +30 -2
- data/lib/mcp_toolkit/resource.rb +173 -6
- data/lib/mcp_toolkit/resource_schema.rb +135 -13
- data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
- data/lib/mcp_toolkit/serializer/base.rb +2 -2
- data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
- data/lib/mcp_toolkit/session.rb +18 -10
- data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
- data/lib/mcp_toolkit/tools/authority_base.rb +148 -0
- 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/transport/controller_methods.rb +2 -2
- data/lib/mcp_toolkit/usage_metering/recorder.rb +121 -0
- data/lib/mcp_toolkit/version.rb +1 -1
- data/lib/mcp_toolkit.rb +16 -3
- metadata +42 -2
- data/app/controllers/mcp_toolkit/server_controller.rb +0 -19
data/lib/mcp_toolkit/engine.rb
CHANGED
|
@@ -1,21 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Mountable Rails engine that draws the
|
|
4
|
-
# engine's config/routes.rb so they survive
|
|
5
|
-
# gem-provided McpToolkit::ServerController
|
|
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
|
-
#
|
|
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
|
|
13
|
-
# GET /mcp
|
|
14
|
-
# DELETE /mcp
|
|
15
|
-
# GET /mcp/health
|
|
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
|
-
#
|
|
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
|
|
@@ -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
|