mcp_toolkit 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -2
  3. data/README.md +315 -34
  4. data/config/routes.rb +20 -5
  5. data/lib/mcp_toolkit/auth/authority.rb +2 -2
  6. data/lib/mcp_toolkit/authority/composite_tool_provider.rb +32 -0
  7. data/lib/mcp_toolkit/authority/context.rb +45 -0
  8. data/lib/mcp_toolkit/authority/controller_methods.rb +423 -0
  9. data/lib/mcp_toolkit/authority/registry_tool_provider.rb +69 -0
  10. data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
  11. data/lib/mcp_toolkit/authority/token.rb +124 -0
  12. data/lib/mcp_toolkit/authority/tools/base.rb +150 -0
  13. data/lib/mcp_toolkit/authority/tools/get.rb +59 -0
  14. data/lib/mcp_toolkit/authority/tools/list.rb +132 -0
  15. data/lib/mcp_toolkit/authority/tools/resource_schema.rb +52 -0
  16. data/lib/mcp_toolkit/authority/tools/resources.rb +55 -0
  17. data/lib/mcp_toolkit/authority.rb +24 -0
  18. data/lib/mcp_toolkit/configuration.rb +362 -5
  19. data/lib/mcp_toolkit/dispatcher.rb +248 -0
  20. data/lib/mcp_toolkit/engine.rb +26 -8
  21. data/lib/mcp_toolkit/engine_controllers.rb +124 -0
  22. data/lib/mcp_toolkit/filtering.rb +168 -23
  23. data/lib/mcp_toolkit/gateway/aggregator.rb +142 -0
  24. data/lib/mcp_toolkit/gateway/client.rb +305 -0
  25. data/lib/mcp_toolkit/gateway/proxy.rb +70 -0
  26. data/lib/mcp_toolkit/gateway/unknown_upstream.rb +8 -0
  27. data/lib/mcp_toolkit/gateway/upstream_call_error.rb +23 -0
  28. data/lib/mcp_toolkit/gateway/upstream_registry.rb +79 -0
  29. data/lib/mcp_toolkit/list_executor.rb +65 -4
  30. data/lib/mcp_toolkit/protocol.rb +106 -0
  31. data/lib/mcp_toolkit/rate_limiter.rb +73 -0
  32. data/lib/mcp_toolkit/registry.rb +30 -2
  33. data/lib/mcp_toolkit/resource.rb +173 -6
  34. data/lib/mcp_toolkit/resource_schema.rb +135 -13
  35. data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
  36. data/lib/mcp_toolkit/serializer/base.rb +2 -2
  37. data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
  38. data/lib/mcp_toolkit/session.rb +18 -10
  39. data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
  40. data/lib/mcp_toolkit/tools/authority_base.rb +148 -0
  41. data/lib/mcp_toolkit/tools/base.rb +23 -3
  42. data/lib/mcp_toolkit/tools/get.rb +1 -1
  43. data/lib/mcp_toolkit/tools/list.rb +27 -9
  44. data/lib/mcp_toolkit/tools/resource_schema.rb +12 -3
  45. data/lib/mcp_toolkit/tools/resources.rb +30 -7
  46. data/lib/mcp_toolkit/transport/controller_methods.rb +2 -2
  47. data/lib/mcp_toolkit/usage_metering/recorder.rb +121 -0
  48. data/lib/mcp_toolkit/version.rb +1 -1
  49. data/lib/mcp_toolkit.rb +16 -3
  50. metadata +42 -2
  51. data/app/controllers/mcp_toolkit/server_controller.rb +0 -19
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ # Minimal MCP client over Streamable HTTP, used by a GATEWAY to talk to an
6
+ # upstream MCP server when aggregating its tool list and proxying tool calls.
7
+ #
8
+ # It speaks the same Streamable-HTTP MCP that McpToolkit::Transport serves:
9
+ # 1. POST `initialize` -> capture the `Mcp-Session-Id` response header
10
+ # 2. POST `notifications/initialized` (a notification; no response expected)
11
+ # 3. POST `tools/list` / `tools/call`, echoing the session header
12
+ #
13
+ # Auth & account selection are pass-through: the caller's bearer token and the
14
+ # account selector (`_meta`) are forwarded so the upstream can introspect the
15
+ # same token against its authority and resolve the same account.
16
+ #
17
+ # Transport notes
18
+ # - Content negotiation: we POST application/json and Accept both
19
+ # application/json and text/event-stream. If the upstream answers with SSE
20
+ # (one message + EOF, as the toolkit's own transport does) we extract the
21
+ # single JSON payload from the `data:` line.
22
+ # - Every public method may raise McpToolkit::Gateway::Client::Error (timeouts,
23
+ # non-2xx, unparseable bodies, JSON-RPC errors). Callers decide whether to
24
+ # degrade (omit from list) or surface the error (proxied call).
25
+ #
26
+ # Everything app-specific (server identity, protocol version, timeout, logger) is
27
+ # injected via McpToolkit::Configuration; nothing here names a deployment.
28
+ class McpToolkit::Gateway::Client
29
+ SESSION_HEADER = "Mcp-Session-Id"
30
+ JSONRPC_VERSION = "2.0"
31
+
32
+ # The protocol version offered on the handshake when the config does not pin
33
+ # one. Sourced from the wrapped `mcp` SDK's latest-supported constant when
34
+ # available, with a literal fallback so the gem still loads if the SDK moves the
35
+ # constant. `config.protocol_version` overrides it.
36
+ DEFAULT_PROTOCOL_VERSION =
37
+ if defined?(MCP::Configuration) && MCP::Configuration.const_defined?(:LATEST_STABLE_PROTOCOL_VERSION)
38
+ MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
39
+ else
40
+ "2025-06-18"
41
+ end
42
+
43
+ # An upstream whose session store is not shared across its pods can answer a
44
+ # NON-initialize request with HTTP 404 (the pod fielding it never saw our
45
+ # initialize), or with a JSON-RPC -32001 "Session not found or expired". Both
46
+ # mean the same thing: our session is gone and a fresh handshake recovers it.
47
+ SESSION_LOSS_HTTP_STATUS = 404
48
+ SESSION_NOT_FOUND_CODE = -32_001
49
+
50
+ # Raised for any upstream failure. `jsonrpc_error` carries an upstream JSON-RPC
51
+ # error hash when the failure was a protocol-level error response, so a proxy
52
+ # can relay it verbatim; nil for transport/HTTP failures. `http_status` carries
53
+ # the HTTP status for a non-2xx response (nil otherwise), so callers can
54
+ # distinguish a session-loss 404 from other failures. This class references NO
55
+ # transport/protocol-error type — the consumer maps it.
56
+ class Error < McpToolkit::Error
57
+ attr_reader :jsonrpc_error, :http_status
58
+
59
+ def initialize(message, jsonrpc_error: nil, http_status: nil)
60
+ super(message)
61
+ @jsonrpc_error = jsonrpc_error
62
+ @http_status = http_status
63
+ end
64
+ end
65
+
66
+ attr_reader :upstream, :bearer_token, :timeout, :config
67
+
68
+ def initialize(upstream:, bearer_token: nil, config: McpToolkit.config)
69
+ @upstream = upstream
70
+ @bearer_token = bearer_token
71
+ @config = config
72
+ @timeout = config.upstream_timeout
73
+ @session_id = nil
74
+ @initialized = false
75
+ end
76
+
77
+ # Returns the upstream's raw tools array (each a tool definition hash with
78
+ # string keys: "name", "description", "inputSchema"). Bare names — the caller
79
+ # namespaces them.
80
+ def tools_list
81
+ with_session_recovery("tools/list") do
82
+ result = rpc!("tools/list")
83
+ Array(result["tools"])
84
+ end
85
+ end
86
+
87
+ # Proxies a tools/call to the upstream. `arguments` and `meta` are forwarded
88
+ # as-is. Returns the upstream's `result` hash verbatim (typically
89
+ # `{ "content" => [...] }`). A JSON-RPC error from the upstream is raised as an
90
+ # Error carrying `jsonrpc_error` so a proxy can relay it.
91
+ def tools_call(name:, arguments: {}, meta: nil)
92
+ params = { "name" => name, "arguments" => arguments }
93
+ params["_meta"] = meta if meta.present?
94
+ with_session_recovery("tools/call #{name}") do
95
+ rpc!("tools/call", params)
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ # The protocol version to offer on the handshake: the config's pin, else the
102
+ # gem's default.
103
+ def protocol_version
104
+ config.protocol_version || DEFAULT_PROTOCOL_VERSION
105
+ end
106
+
107
+ # Runs a request-bearing RPC against an initialized session, transparently
108
+ # recovering from a SINGLE session-loss response: if the upstream reports our
109
+ # session is gone (see SESSION_LOSS_HTTP_STATUS / SESSION_NOT_FOUND_CODE) we
110
+ # drop the dead session, re-handshake, and retry the block exactly ONCE. Any
111
+ # other error — a genuine JSON-RPC tool error, a timeout, a non-404 HTTP
112
+ # failure — propagates verbatim, unchanged. An upstream tool that returns an
113
+ # `isError` content result is a normal return value here (not an exception), so
114
+ # it passes straight through.
115
+ def with_session_recovery(operation, &)
116
+ ensure_initialized!
117
+ yield
118
+ rescue Error => e
119
+ raise unless session_loss?(e)
120
+
121
+ recover_and_retry(operation, e, &)
122
+ end
123
+
124
+ # Second (and final) attempt after a session-loss signal: re-establish the
125
+ # session and run the block once more. Bounded to one retry — this method does
126
+ # not recurse into `with_session_recovery`, so it cannot loop. If re-init or the
127
+ # retry itself fails, the error is clarified before it propagates.
128
+ def recover_and_retry(operation, original_error)
129
+ log_session_recovery(operation, original_error)
130
+ reset_session!
131
+ ensure_initialized!
132
+ yield
133
+ rescue Error => e
134
+ raise clarified_session_error(e, operation)
135
+ end
136
+
137
+ # Performs the initialize handshake once per client instance, capturing the
138
+ # session id, then sends the `notifications/initialized` notification.
139
+ def ensure_initialized!
140
+ return if @initialized
141
+
142
+ response = post_jsonrpc(
143
+ jsonrpc_request("initialize", {
144
+ "protocolVersion" => protocol_version,
145
+ "capabilities" => {},
146
+ # The GATEWAY-client identity (falls back to the server
147
+ # identity), split so an authority can advertise its own
148
+ # server_name to callers while keeping this upstream
149
+ # handshake byte-identical.
150
+ "clientInfo" => {
151
+ "name" => config.gateway_client_name,
152
+ "version" => config.gateway_client_version
153
+ }
154
+ })
155
+ )
156
+ @session_id = response.headers[SESSION_HEADER].presence
157
+ parse_rpc_result!(response)
158
+
159
+ # Best-effort lifecycle notification; upstreams may ignore it. A notification
160
+ # returns no body (202), so we don't parse a result.
161
+ post_jsonrpc(jsonrpc_notification("notifications/initialized"))
162
+
163
+ @initialized = true
164
+ end
165
+
166
+ # Drops the current session so the next `ensure_initialized!` re-handshakes.
167
+ def reset_session!
168
+ @initialized = false
169
+ @session_id = nil
170
+ end
171
+
172
+ # True when an upstream error signals our session is gone and a fresh handshake
173
+ # could recover it: a bare HTTP 404, or a JSON-RPC -32001 session error.
174
+ def session_loss?(error)
175
+ error.http_status == SESSION_LOSS_HTTP_STATUS ||
176
+ error.jsonrpc_error&.dig("code") == SESSION_NOT_FOUND_CODE
177
+ end
178
+
179
+ # A session-loss failure we could NOT recover from (already retried once).
180
+ # Preserve verbatim relay for a genuine JSON-RPC error and pass through any
181
+ # unrelated failure; only rewrite the bare "returned HTTP 404", whose message
182
+ # hides the real cause.
183
+ def clarified_session_error(error, operation)
184
+ return error if error.jsonrpc_error
185
+ return error unless session_loss?(error)
186
+
187
+ Error.new(
188
+ "upstream #{upstream.key} #{operation} failed: the MCP session was lost and could not " \
189
+ "be re-established after re-initializing the handshake",
190
+ http_status: error.http_status
191
+ )
192
+ end
193
+
194
+ def log_session_recovery(operation, error)
195
+ config.logger&.warn(
196
+ "MCP upstream #{upstream.key} #{operation}: session lost (#{session_loss_reason(error)}), " \
197
+ "re-initializing and retrying once"
198
+ )
199
+ end
200
+
201
+ def session_loss_reason(error)
202
+ return "JSON-RPC #{error.jsonrpc_error["code"]}" if error.jsonrpc_error
203
+ return "HTTP #{error.http_status}" if error.http_status
204
+
205
+ "unknown"
206
+ end
207
+
208
+ # Sends a request-bearing JSON-RPC call and returns its `result`.
209
+ def rpc!(method, params = {})
210
+ response = post_jsonrpc(jsonrpc_request(method, params))
211
+ parse_rpc_result!(response)
212
+ end
213
+
214
+ def jsonrpc_request(method, params = {})
215
+ { "jsonrpc" => JSONRPC_VERSION, "id" => SecureRandom.uuid, "method" => method, "params" => params }
216
+ end
217
+
218
+ def jsonrpc_notification(method, params = {})
219
+ { "jsonrpc" => JSONRPC_VERSION, "method" => method, "params" => params }
220
+ end
221
+
222
+ def post_jsonrpc(body)
223
+ connection.post(upstream.url) do |request|
224
+ apply_request_headers(request.headers)
225
+ request.body = JSON.generate(body)
226
+ end
227
+ rescue Faraday::TimeoutError => e
228
+ raise upstream_error("timed out after #{timeout}s", e)
229
+ rescue Faraday::Error => e
230
+ raise upstream_error("request failed", e)
231
+ end
232
+
233
+ def upstream_error(reason, cause)
234
+ Error.new("upstream #{upstream.key} #{reason}: #{cause.message}")
235
+ end
236
+
237
+ # Sets the content-negotiation, auth, and session headers on an outgoing
238
+ # request. Auth and session headers are added only when present.
239
+ def apply_request_headers(headers)
240
+ headers["Content-Type"] = "application/json"
241
+ headers["Accept"] = "application/json, text/event-stream"
242
+ headers["Authorization"] = "Bearer #{bearer_token}" if bearer_token.present?
243
+ headers[SESSION_HEADER] = @session_id if @session_id.present?
244
+ end
245
+
246
+ # Validates the HTTP response and extracts the JSON-RPC `result`, raising on a
247
+ # JSON-RPC error (with the error hash attached for verbatim relay).
248
+ def parse_rpc_result!(response)
249
+ raise_http_error!(response) unless response.success?
250
+
251
+ payload = decode_body(response)
252
+ return {} if payload.nil? # e.g. 202 Accepted for a notification
253
+
254
+ if payload["error"]
255
+ raise Error.new(
256
+ "upstream #{upstream.key} JSON-RPC error: #{payload.dig("error", "message")}",
257
+ jsonrpc_error: payload["error"]
258
+ )
259
+ end
260
+
261
+ payload["result"] || {}
262
+ end
263
+
264
+ # The `http_status` is carried on the Error so a session-loss 404 can be told
265
+ # apart from other non-2xx failures (see `session_loss?`).
266
+ def raise_http_error!(response)
267
+ raise Error.new("upstream #{upstream.key} returned HTTP #{response.status}", http_status: response.status)
268
+ end
269
+
270
+ # Decodes either a plain JSON body or a single SSE `data:` frame.
271
+ def decode_body(response)
272
+ json = json_payload(response)
273
+ return nil if json.nil?
274
+
275
+ JSON.parse(json)
276
+ rescue JSON::ParserError => e
277
+ raise Error, "upstream #{upstream.key} returned unparseable body: #{e.message}"
278
+ end
279
+
280
+ # Extracts the JSON text to parse, unwrapping an SSE frame when the response is
281
+ # an event stream. Returns nil for an empty body or an empty/absent frame.
282
+ def json_payload(response)
283
+ body = response.body.to_s
284
+ return nil if body.strip.empty?
285
+
286
+ content_type = response.headers["Content-Type"].to_s
287
+ json = content_type.include?("text/event-stream") ? extract_sse_data(body) : body
288
+ json unless json.nil? || json.strip.empty?
289
+ end
290
+
291
+ # Pulls the JSON payload from the first `data:` line of an SSE stream.
292
+ def extract_sse_data(body)
293
+ body.each_line.filter_map do |line|
294
+ line.start_with?("data:") ? line.sub(/\Adata:\s?/, "").chomp : nil
295
+ end.first
296
+ end
297
+
298
+ def connection
299
+ @connection ||= Faraday.new do |conn|
300
+ conn.options.timeout = timeout
301
+ conn.options.open_timeout = timeout
302
+ conn.adapter Faraday.default_adapter
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Proxies a namespaced upstream tool call (`<app>__<tool>`) to its upstream MCP
4
+ # server.
5
+ #
6
+ # The account selector is forwarded as `_meta[config.account_meta_key]` when an
7
+ # `account_id` is supplied. The caller passes the already-resolved account id (a
8
+ # scalar), not a domain object — resolving the tenant is the consumer's job.
9
+ #
10
+ # Error mapping is deliberately thin and transport-agnostic:
11
+ # * an unregistered `app_key` raises McpToolkit::Gateway::UnknownUpstream;
12
+ # * an upstream call failure is re-raised as McpToolkit::Gateway::UpstreamCallError
13
+ # carrying the upstream's `jsonrpc_error` / `http_status`.
14
+ # Neither is mapped to a JSON-RPC/protocol error class here — the consuming
15
+ # dispatcher does that at its call site.
16
+ class McpToolkit::Gateway::Proxy
17
+ attr_reader :app_key, :tool_name, :account_id, :bearer_token, :config
18
+
19
+ def initialize(app_key:, tool_name:, account_id: nil, bearer_token: nil, config: McpToolkit.config)
20
+ @app_key = app_key
21
+ @tool_name = tool_name
22
+ @account_id = account_id
23
+ @bearer_token = bearer_token
24
+ @config = config
25
+ end
26
+
27
+ def call(arguments)
28
+ upstream = config.upstreams.find(app_key)
29
+ raise McpToolkit::Gateway::UnknownUpstream, "Unknown application: #{app_key}" if upstream.nil?
30
+
31
+ client = McpToolkit::Gateway::Client.new(upstream:, bearer_token:, config:)
32
+ client.tools_call(name: tool_name, arguments:, meta:)
33
+ rescue McpToolkit::Gateway::Client::Error => e
34
+ relay_upstream_error(e)
35
+ end
36
+
37
+ private
38
+
39
+ def relay_upstream_error(error)
40
+ log_proxied_failure(error)
41
+
42
+ raise McpToolkit::Gateway::UpstreamCallError.new(
43
+ error.message,
44
+ jsonrpc_error: error.jsonrpc_error,
45
+ http_status: error.http_status
46
+ )
47
+ end
48
+
49
+ # Emit one concise, greppable ERROR line per failed proxied call — never a
50
+ # bearer token or a full response body.
51
+ def log_proxied_failure(error)
52
+ config.logger&.error(
53
+ "MCP upstream #{app_key} tools/call #{tool_name} failed#{failure_detail(error)}: #{error.message}"
54
+ )
55
+ end
56
+
57
+ def failure_detail(error)
58
+ code = error.jsonrpc_error&.dig("code")
59
+ return " (jsonrpc_code=#{code})" if code
60
+ return " (http_status=#{error.http_status})" if error.http_status
61
+
62
+ ""
63
+ end
64
+
65
+ def meta
66
+ return nil if account_id.nil?
67
+
68
+ { config.account_meta_key => account_id }
69
+ end
70
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Raised by McpToolkit::Gateway::Proxy when a namespaced tool call targets an
4
+ # upstream key that is not registered. The GEM does NOT translate this into any
5
+ # JSON-RPC / protocol error class — the consuming dispatcher maps it to whatever
6
+ # error shape its transport speaks (e.g. a "method not found"). Kept a plain
7
+ # McpToolkit::Error so the gem stays transport-agnostic.
8
+ class McpToolkit::Gateway::UnknownUpstream < McpToolkit::Error; end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Raised by McpToolkit::Gateway::Proxy when a proxied `tools/call` fails at the
4
+ # upstream. It carries the upstream failure detail so the CONSUMER can decide how
5
+ # to surface it:
6
+ #
7
+ # * `jsonrpc_error` — the upstream's JSON-RPC error hash when the failure was a
8
+ # protocol-level error response (so the consumer can relay it verbatim); nil
9
+ # for transport/HTTP failures.
10
+ # * `http_status` — the HTTP status for a non-2xx response (nil otherwise).
11
+ #
12
+ # The gem deliberately does NOT map this to a protocol/transport error class:
13
+ # that mapping lives in the consuming dispatcher, keeping the gateway
14
+ # transport-agnostic.
15
+ class McpToolkit::Gateway::UpstreamCallError < McpToolkit::Error
16
+ attr_reader :jsonrpc_error, :http_status
17
+
18
+ def initialize(message, jsonrpc_error: nil, http_status: nil)
19
+ super(message)
20
+ @jsonrpc_error = jsonrpc_error
21
+ @http_status = http_status
22
+ end
23
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Registry of upstream MCP servers a GATEWAY app aggregates and proxies to.
4
+ #
5
+ # Each upstream has a `key` (the tool-name namespace prefix — its tools are
6
+ # surfaced as `<key>__<tool>`) and a `url` (the upstream's MCP HTTP endpoint).
7
+ # Upstreams are registered at boot (typically from ENV); an upstream whose url is
8
+ # blank is never registered, so an unconfigured environment behaves exactly like
9
+ # a gateway with no upstreams.
10
+ #
11
+ # Unlike a global module singleton, this is a PER-CONFIG instance (like
12
+ # `config.registry`): each `McpToolkit::Configuration` carries its own, exposed as
13
+ # `config.upstreams`. That gives test isolation for free (a fresh config resets
14
+ # it) and matches the gem's per-config convention. Register via the config sugar
15
+ # `config.register_upstream(key:, url:)` or directly on `config.upstreams`.
16
+ class McpToolkit::Gateway::UpstreamRegistry
17
+ # Separates an app key from a tool name in an aggregated tool name. A double
18
+ # underscore so it doesn't collide with single underscores in bare tool names
19
+ # (e.g. "list_items"); it also matches the gem's `<app>__<action>` scope
20
+ # separator.
21
+ NAMESPACE_SEPARATOR = "__"
22
+
23
+ # `public_tool_list` is the gateway-cache contract: an upstream's `tools/list`
24
+ # MUST be caller-INDEPENDENT (the same public tools for every valid token; scope
25
+ # is enforced only when a tool is CALLED). That invariant is what lets the
26
+ # Aggregator share one cache entry per upstream across all callers. An upstream
27
+ # that filters its list by the caller's privilege (e.g. hides superuser-only
28
+ # tools) breaks it and MUST register `public_tool_list: false`, which opts it out
29
+ # of the shared cache so a privileged caller's list can't leak to an
30
+ # unprivileged one. Default true — the overwhelming common case.
31
+ Upstream = Data.define(:key, :url, :public_tool_list) do
32
+ def initialize(key:, url:, public_tool_list: true)
33
+ super
34
+ end
35
+
36
+ def name_for(tool_name)
37
+ "#{key}#{NAMESPACE_SEPARATOR}#{tool_name}"
38
+ end
39
+ end
40
+
41
+ def initialize
42
+ @registered = {}
43
+ end
44
+
45
+ # Registers an upstream by key. A blank url is ignored, so callers can pass an
46
+ # ENV lookup directly without guarding it. Pass `public_tool_list: false` for an
47
+ # upstream whose tool list varies by caller privilege, opting it out of the
48
+ # shared list cache (see the Upstream contract above).
49
+ def register(key:, url:, public_tool_list: true)
50
+ return if url.blank?
51
+
52
+ @registered[key.to_s] = Upstream.new(key: key.to_s, url:, public_tool_list:)
53
+ end
54
+
55
+ # Clears every registered upstream.
56
+ def reset!
57
+ @registered = {}
58
+ end
59
+
60
+ # All registered upstreams (insertion order).
61
+ def all
62
+ @registered.values
63
+ end
64
+
65
+ # The registered upstream for a key, or nil.
66
+ def find(key)
67
+ @registered[key.to_s]
68
+ end
69
+
70
+ # `<app>__<tool>` -> [app_key, bare_tool_name] for a registered upstream; nil
71
+ # for an un-namespaced name or an unknown/unregistered key.
72
+ def split_tool_name(tool_name)
73
+ prefix, separator, rest = tool_name.to_s.partition(NAMESPACE_SEPARATOR)
74
+ return nil if separator.empty? || rest.empty?
75
+ return nil unless find(prefix)
76
+
77
+ [prefix, rest]
78
+ end
79
+ end
@@ -45,14 +45,41 @@ class McpToolkit::ListExecutor
45
45
  relation = resource.resolve_relation(scope_root)
46
46
  relation = apply_ids(relation)
47
47
  relation = apply_updated_since(relation)
48
+ relation = apply_custom_filters(relation)
48
49
  relation = apply_attribute_filters(relation)
49
50
  apply_order(relation)
50
51
  end
51
52
 
52
- # Order by `id` when the primary key is numeric; otherwise by `created_at`
53
- # (a non-numeric PK does not sort meaningfully).
53
+ # Applies the resource's declared custom (resource-specific) filters each an
54
+ # arbitrary host-supplied block for the keys actually present as TOP-LEVEL
55
+ # request params, BEFORE the generic allowlist `filter` attributes. Each block
56
+ # receives the already-scoped relation and the raw value and returns a narrowed
57
+ # relation, so a host can express a relational filter the generic path can't
58
+ # derive (see Resource#filter). A resource with no custom filters is a no-op.
59
+ def apply_custom_filters(relation)
60
+ resource.custom_filters.each_value do |custom_filter|
61
+ value = params[custom_filter.name]
62
+ next if value.nil? || value == ""
63
+
64
+ relation = custom_filter.applier.call(relation, value)
65
+ end
66
+ relation
67
+ end
68
+
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.
54
76
  def apply_order(relation)
55
- relation.order(numeric_primary_key? ? :id : :created_at)
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)
56
83
  end
57
84
 
58
85
  def numeric_primary_key?
@@ -81,9 +108,10 @@ class McpToolkit::ListExecutor
81
108
 
82
109
  mapping = resource.filterable_columns
83
110
  validate_filter_keys!(filter, mapping)
111
+ validate_filter_companions!(filter)
84
112
 
85
113
  filter.each do |request_key, value|
86
- next if value.nil? || value == ""
114
+ next if skipped_filter_value?(value)
87
115
 
88
116
  column = mapping[request_key.to_sym]
89
117
  relation = McpToolkit::Filtering.apply(relation, column, value)
@@ -91,6 +119,35 @@ class McpToolkit::ListExecutor
91
119
  relation
92
120
  end
93
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
+
94
151
  def validate_filter_keys!(filter, mapping)
95
152
  keys = filter.keys.map(&:to_sym)
96
153
  unknown = keys - mapping.keys
@@ -106,6 +163,10 @@ class McpToolkit::ListExecutor
106
163
  return relation if params[:ids].blank?
107
164
 
108
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)
109
170
  ids.empty? ? relation : relation.where(id: ids)
110
171
  end
111
172