mcp_toolkit 0.3.0 → 0.4.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +201 -2
  3. data/README.md +315 -34
  4. data/config/routes.rb +19 -4
  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 +408 -0
  9. data/lib/mcp_toolkit/authority/registry_tool_provider.rb +70 -0
  10. data/lib/mcp_toolkit/authority/token.rb +124 -0
  11. data/lib/mcp_toolkit/authority/tools/base.rb +122 -0
  12. data/lib/mcp_toolkit/authority/tools/get.rb +58 -0
  13. data/lib/mcp_toolkit/authority/tools/list.rb +84 -0
  14. data/lib/mcp_toolkit/authority/tools/resource_schema.rb +44 -0
  15. data/lib/mcp_toolkit/authority/tools/resources.rb +33 -0
  16. data/lib/mcp_toolkit/authority.rb +24 -0
  17. data/lib/mcp_toolkit/configuration.rb +205 -2
  18. data/lib/mcp_toolkit/dispatcher.rb +234 -0
  19. data/lib/mcp_toolkit/engine.rb +22 -7
  20. data/lib/mcp_toolkit/engine_controllers.rb +116 -0
  21. data/lib/mcp_toolkit/gateway/aggregator.rb +122 -0
  22. data/lib/mcp_toolkit/gateway/client.rb +305 -0
  23. data/lib/mcp_toolkit/gateway/proxy.rb +70 -0
  24. data/lib/mcp_toolkit/gateway/unknown_upstream.rb +8 -0
  25. data/lib/mcp_toolkit/gateway/upstream_call_error.rb +23 -0
  26. data/lib/mcp_toolkit/gateway/upstream_registry.rb +79 -0
  27. data/lib/mcp_toolkit/list_executor.rb +17 -0
  28. data/lib/mcp_toolkit/protocol.rb +106 -0
  29. data/lib/mcp_toolkit/rate_limiter.rb +73 -0
  30. data/lib/mcp_toolkit/registry.rb +30 -2
  31. data/lib/mcp_toolkit/resource.rb +125 -7
  32. data/lib/mcp_toolkit/resource_schema.rb +14 -1
  33. data/lib/mcp_toolkit/serializer/base.rb +2 -2
  34. data/lib/mcp_toolkit/session.rb +17 -9
  35. data/lib/mcp_toolkit/tools/authority_base.rb +127 -0
  36. data/lib/mcp_toolkit/transport/controller_methods.rb +2 -2
  37. data/lib/mcp_toolkit/usage_metering/recorder.rb +95 -0
  38. data/lib/mcp_toolkit/version.rb +1 -1
  39. data/lib/mcp_toolkit.rb +16 -3
  40. metadata +38 -2
  41. data/app/controllers/mcp_toolkit/server_controller.rb +0 -19
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Hand-rolled JSON-RPC dispatcher for the AUTHORITY + gateway path: it handles a
4
+ # single JSON-RPC request (one element of a batch), serving the host's own tools
5
+ # and, as a gateway, aggregating + proxying upstream MCP servers — WITHOUT the
6
+ # official `mcp` SDK in the request path.
7
+ #
8
+ # The gem carries two dispatch front-ends by design (see McpToolkit::Protocol):
9
+ # this Dispatcher for the authority/gateway endpoint, and McpToolkit::Server.build
10
+ # (the SDK wrapper) for satellites. They are independent; nothing here touches the
11
+ # satellite path.
12
+ #
13
+ # Everything host-specific is injected:
14
+ # * `context` — an McpToolkit::Authority::Context (the resolved account, the
15
+ # authenticated principal, and the bearer token to forward
16
+ # upstream). Re-created PER JSON-RPC request by the transport, so
17
+ # each batch element carries its own account.
18
+ # * `config` — server identity (`server_name`/`server_version`), the
19
+ # negotiable protocol versions, the registered `upstreams`, and
20
+ # the `tool_provider` (the host's api-agnostic tool catalog).
21
+ #
22
+ # The wire behavior — top-level JSON-RPC tool-error codes, `initialize`
23
+ # capabilities `{ tools: { listChanged: true } }`, 3-version negotiation, verbatim
24
+ # upstream error relay, and the custom `notifications/<app>/tools/list_changed`
25
+ # cache-bust — is the byte contract of a first-party endpoint and is preserved
26
+ # exactly.
27
+ class McpToolkit::Dispatcher
28
+ attr_reader :context, :config
29
+
30
+ def initialize(context:, config: McpToolkit.config)
31
+ @context = context
32
+ @config = config
33
+ end
34
+
35
+ def handle_request(request)
36
+ dispatch_request(request)
37
+ rescue McpToolkit::Protocol::Error => e
38
+ return nil unless request.key?("id")
39
+
40
+ McpToolkit::Protocol.error_response(id: request["id"], error: e)
41
+ rescue StandardError => e
42
+ config.logger&.error("MCP dispatcher error: #{e.message}\n#{e.backtrace&.join("\n")}")
43
+ return nil unless request.key?("id")
44
+
45
+ McpToolkit::Protocol.error_response(
46
+ id: request["id"],
47
+ error: McpToolkit::Protocol::InternalError.new(e.message)
48
+ )
49
+ end
50
+
51
+ private
52
+
53
+ # Happy path; raises here are turned into JSON-RPC errors by handle_request.
54
+ def dispatch_request(request)
55
+ validate_request!(request)
56
+
57
+ result = dispatch_method(request["method"], request["params"] || {})
58
+
59
+ # JSON-RPC 2.0: notifications (requests without `id`) MUST NOT receive a response.
60
+ return nil unless request.key?("id")
61
+
62
+ McpToolkit::Protocol.success_response(id: request["id"], result:)
63
+ end
64
+
65
+ def validate_request!(request)
66
+ unless request["jsonrpc"] == McpToolkit::Protocol::JSONRPC_VERSION
67
+ raise McpToolkit::Protocol::InvalidRequest, "Missing jsonrpc version"
68
+ end
69
+ raise McpToolkit::Protocol::InvalidRequest, "Missing method" if request["method"].blank?
70
+ end
71
+
72
+ def dispatch_method(method, params)
73
+ case method
74
+ when "initialize"
75
+ handle_initialize(params)
76
+ when "initialized", "notifications/initialized"
77
+ handle_initialized
78
+ when "tools/list"
79
+ handle_tools_list
80
+ when "tools/call"
81
+ handle_tools_call(params)
82
+ when "ping"
83
+ handle_ping
84
+ else
85
+ return handle_upstream_list_changed(method) if upstream_list_changed_notification?(method)
86
+
87
+ raise McpToolkit::Protocol::MethodNotFound, method
88
+ end
89
+ end
90
+
91
+ # Satellites can tell the authority their tool list changed via a
92
+ # `notifications/<app>/tools/list_changed` notification, busting that upstream's
93
+ # cached aggregate. Matches the configured upstream keys only.
94
+ def upstream_list_changed_notification?(method)
95
+ upstream_key_from_notification(method).present?
96
+ end
97
+
98
+ def handle_upstream_list_changed(method)
99
+ McpToolkit::Gateway::Aggregator.new(config:).flush!(upstream_key_from_notification(method))
100
+ {}
101
+ end
102
+
103
+ def upstream_key_from_notification(method)
104
+ match = method.to_s.match(%r{\Anotifications/(?<key>.+)/tools/list_changed\z})
105
+ return nil unless match
106
+
107
+ config.upstreams.find(match[:key]) ? match[:key] : nil
108
+ end
109
+
110
+ def handle_initialize(params)
111
+ requested = params["protocolVersion"].to_s
112
+ versions = config.supported_protocol_versions
113
+ negotiated = versions.include?(requested) ? requested : versions.first
114
+
115
+ result = {
116
+ protocolVersion: negotiated,
117
+ capabilities: {
118
+ # listChanged: true — the aggregated list includes upstream tools, which
119
+ # can change when an upstream is reconfigured or sends a list_changed
120
+ # notification that busts the cached aggregate.
121
+ tools: { listChanged: true }
122
+ },
123
+ serverInfo: {
124
+ name: config.server_name,
125
+ version: config.server_version
126
+ }
127
+ }
128
+
129
+ # `instructions` is advertised on initialize only when configured, matching the
130
+ # SDK-backed satellite server (McpToolkit::Server.build) and the documented
131
+ # contract. Omitted when nil to keep the envelope clean.
132
+ result[:instructions] = config.server_instructions if config.server_instructions
133
+ result
134
+ end
135
+
136
+ def handle_initialized
137
+ {}
138
+ end
139
+
140
+ def handle_ping
141
+ {}
142
+ end
143
+
144
+ def handle_tools_list
145
+ {
146
+ tools: host_tool_definitions +
147
+ McpToolkit::Gateway::Aggregator.new(config:).tool_definitions(bearer_token: context.bearer_token)
148
+ }
149
+ end
150
+
151
+ # The host's own tool definitions, sourced from the injected tool_provider (the
152
+ # api-agnostic seam). `context` lets the provider hide superuser-only tools from
153
+ # a non-superuser caller. A host that registered no provider contributes none.
154
+ def host_tool_definitions
155
+ provider = config.tool_provider
156
+ return [] unless provider
157
+
158
+ provider.tool_definitions(context)
159
+ end
160
+
161
+ def handle_tools_call(params)
162
+ tool_name = params["name"]
163
+ arguments = params["arguments"] || {}
164
+
165
+ upstream = config.upstreams.split_tool_name(tool_name)
166
+ return handle_upstream_tools_call(upstream, arguments) if upstream
167
+
168
+ handle_host_tools_call(tool_name, arguments)
169
+ end
170
+
171
+ def handle_host_tools_call(tool_name, arguments)
172
+ tool = config.tool_provider&.find(tool_name)
173
+ raise McpToolkit::Protocol::MethodNotFound, "Tool not found: #{tool_name}" unless tool
174
+
175
+ ensure_tool_scope!(tool)
176
+
177
+ result = tool.call(context:, **symbolized_arguments(arguments))
178
+
179
+ {
180
+ content: [
181
+ {
182
+ type: "text",
183
+ text: result.is_a?(String) ? result : result.to_json
184
+ }
185
+ ]
186
+ }
187
+ end
188
+
189
+ # JSON gives string keys; a tool's `call(context:, **arguments)` needs symbol
190
+ # keys for the keyword splat. Deep-symbolized so nested argument hashes reach
191
+ # the tool in the same shape a symbol-keyed caller would pass.
192
+ def symbolized_arguments(arguments)
193
+ arguments.to_h.deep_symbolize_keys
194
+ end
195
+
196
+ def ensure_tool_scope!(tool)
197
+ required_scope = tool.required_permissions_scope
198
+ return if required_scope.blank?
199
+ return if context.principal&.authorized_for_scope?(required_scope)
200
+
201
+ raise McpToolkit::Protocol::InvalidRequest, "This token lacks the #{required_scope.inspect} scope"
202
+ end
203
+
204
+ def handle_upstream_tools_call((app_key, bare_tool_name), arguments)
205
+ McpToolkit::Gateway::Proxy.new(
206
+ app_key:,
207
+ tool_name: bare_tool_name,
208
+ account_id: context.account&.id,
209
+ bearer_token: context.bearer_token,
210
+ config:
211
+ ).call(arguments)
212
+ rescue McpToolkit::Gateway::UnknownUpstream => e
213
+ # The gateway stays transport-agnostic; the dispatcher maps an unknown
214
+ # upstream to its own "method not found".
215
+ raise McpToolkit::Protocol::MethodNotFound, e.message
216
+ rescue McpToolkit::Gateway::UpstreamCallError => e
217
+ raise translate_upstream_call_error(e)
218
+ end
219
+
220
+ # Translates the gateway's transport-agnostic upstream failure into the JSON-RPC
221
+ # error shape — verbatim relay of a satellite JSON-RPC error, else a generic
222
+ # internal error.
223
+ def translate_upstream_call_error(error)
224
+ if error.jsonrpc_error
225
+ McpToolkit::Protocol::Error.new(
226
+ error.jsonrpc_error["message"].to_s,
227
+ code: error.jsonrpc_error["code"] || McpToolkit::Protocol::ErrorCodes::INTERNAL_ERROR,
228
+ data: error.jsonrpc_error["data"]
229
+ )
230
+ else
231
+ McpToolkit::Protocol::InternalError.new(error.message)
232
+ end
233
+ end
234
+ end
@@ -1,21 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Mountable Rails engine that draws the four MCP transport routes (defined in the
4
- # engine's config/routes.rb so they survive Rails' route reloads) against the
5
- # gem-provided McpToolkit::ServerController. A satellite mounts it in one line:
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 mounts it in one line:
6
7
  #
7
8
  # # config/routes.rb
8
9
  # mount McpToolkit::Engine => "/mcp"
9
10
  #
10
11
  # yielding exactly the endpoints a hand-rolled satellite declared:
11
12
  #
12
- # POST /mcp -> create (JSON-RPC requests/responses)
13
- # GET /mcp -> stream (405; no server-initiated SSE)
14
- # DELETE /mcp -> destroy (terminate the session)
15
- # GET /mcp/health -> health (unauthenticated probe)
13
+ # POST /mcp -> create (JSON-RPC requests/responses)
14
+ # GET /mcp -> stream (405; no server-initiated SSE)
15
+ # DELETE /mcp -> destroy (terminate the session)
16
+ # GET /mcp/health -> health (unauthenticated probe)
17
+ # POST /mcp/tokens/introspect -> introspect (authority token introspection;
18
+ # drawn ONLY when auth_role is
19
+ # :authority — a satellite that
20
+ # mounts the engine gets no such
21
+ # route)
16
22
  #
17
23
  # Loaded ONLY when Rails::Engine is available (see lib/mcp_toolkit.rb); the gem's
18
24
  # non-Rails consumers and its own unit suite never reference it.
19
25
  class McpToolkit::Engine < Rails::Engine
20
26
  isolate_namespace McpToolkit
27
+
28
+ # The gem-provided controllers subclass `config.parent_controller`, which the
29
+ # host sets in an initializer/to_prepare that must be READ AFTER it runs
30
+ # (Constraint B). They are therefore built lazily by
31
+ # `McpToolkit.build_engine_controllers!` (triggered via const_missing on first
32
+ # reference — at eager-load or first request). This resets them on every code
33
+ # reload so a changed parent (or a reloaded app parent class) takes effect on the
34
+ # next reference. Runs before `:eager_load!`, so the fresh classes exist for it.
35
+ config.to_prepare { McpToolkit.reset_engine_controllers! }
21
36
  end
@@ -0,0 +1,116 @@
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 SATELLITE transport controller the engine mounts (unchanged behavior; the
53
+ # SDK-backed path). Built lazily only so its parent is read after config.
54
+ def self.build_server_controller(parent)
55
+ Class.new(parent) { include McpToolkit::Transport::ControllerMethods }
56
+ end
57
+
58
+ # The AUTHORITY introspection endpoint the engine mounts at
59
+ # `POST /mcp/tokens/introspect`. Behavior is preserved exactly: it authenticates
60
+ # the bearer against `config.token_authenticator` (via Auth::Authority) and
61
+ # renders the introspection payload; a non-authority app answers `{ valid:
62
+ # false }` rather than erroring.
63
+ def self.build_tokens_controller(parent)
64
+ Class.new(parent) do
65
+ def introspect
66
+ token = McpToolkit::Auth::Authority.authenticate(mcp_extract_token, config: mcp_config)
67
+ return render(json: McpToolkit::Auth::Authority.invalid_payload, status: :unauthorized) if token.nil?
68
+
69
+ render json: McpToolkit::Auth::Authority.introspection_payload(token)
70
+ rescue McpToolkit::Errors::ConfigurationError
71
+ # Not configured as an authority (no token_authenticator): behave as if the
72
+ # token were invalid instead of surfacing a 500.
73
+ render json: McpToolkit::Auth::Authority.invalid_payload, status: :unauthorized
74
+ end
75
+
76
+ private
77
+
78
+ def mcp_config
79
+ McpToolkit.config
80
+ end
81
+
82
+ def mcp_extract_token
83
+ auth_header = request.headers["Authorization"]
84
+ return auth_header.sub("Bearer ", "") if auth_header&.start_with?("Bearer ")
85
+
86
+ request.headers["X-MCP-Token"].presence || params[:token].presence
87
+ end
88
+ end
89
+ end
90
+
91
+ # The AUTHORITY base controller a host subclasses (the recommended path for a
92
+ # host whose rate-limit/usage/account hooks touch app models).
93
+ def self.build_authority_server_controller(parent)
94
+ Class.new(parent) { include McpToolkit::Authority::ControllerMethods }
95
+ end
96
+
97
+ # Removes an existing same-named constant (avoiding a redefinition warning on a
98
+ # rebuild) before setting the freshly-built class.
99
+ def self.define_controller(mod, name, klass)
100
+ mod.send(:remove_const, name) if mod.const_defined?(name, false)
101
+ mod.const_set(name, klass)
102
+ end
103
+
104
+ # Backstop: build the engine controllers the first time one is referenced before
105
+ # any `to_prepare`/eager-load pass has built them (e.g. a bespoke route that
106
+ # names McpToolkit::ServerController directly). McpToolkit::Authority defines the
107
+ # sibling backstop for its ServerController.
108
+ def self.const_missing(name)
109
+ if ENGINE_CONTROLLER_NAMES.include?(name)
110
+ build_engine_controllers!
111
+ return const_get(name) if const_defined?(name, false)
112
+ end
113
+
114
+ super
115
+ end
116
+ end
@@ -0,0 +1,122 @@
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
+ end
100
+
101
+ def live_definitions(upstream, bearer_token:)
102
+ client = McpToolkit::Gateway::Client.new(upstream:, bearer_token:, config:)
103
+ client.tools_list.map do |definition|
104
+ namespaced(upstream, definition)
105
+ end
106
+ end
107
+
108
+ # Re-keys an upstream tool definition into the gateway's aggregate namespace.
109
+ def namespaced(upstream, definition)
110
+ definition = definition.dup
111
+ definition["name"] = upstream.name_for(definition["name"])
112
+ definition
113
+ end
114
+
115
+ def cache
116
+ config.cache_store
117
+ end
118
+
119
+ def cache_key(key)
120
+ "#{CACHE_KEY_PREFIX}#{key}"
121
+ end
122
+ end