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
@@ -25,6 +25,24 @@ class McpToolkit::Configuration
25
25
  # @return [String, nil] human-readable `instructions` returned on `initialize`.
26
26
  attr_accessor :server_instructions
27
27
 
28
+ # --- gateway client identity (identity split) ------------------------------
29
+
30
+ # The `clientInfo` an app presents when it acts as a GATEWAY talking to its
31
+ # upstream MCP servers (McpToolkit::Gateway::Client's handshake). Split from the
32
+ # SERVER identity (`server_name`/`server_version`, advertised to the app's OWN
33
+ # callers) so an authority can present its real server identity downstream while
34
+ # keeping its upstream handshake byte-identical to a prior deployment. Both
35
+ # default to the server identity, so a satellite/gateway that doesn't care sets
36
+ # nothing.
37
+ #
38
+ # c.server_name = "acme-mcp" # advertised to our callers
39
+ # c.gateway_client_name = "acme-mcp-gateway" # presented to our upstreams
40
+ #
41
+ # @return [String] gateway handshake client name (defaults to `server_name`).
42
+ attr_writer :gateway_client_name
43
+ # @return [String] gateway handshake client version (defaults to `server_version`).
44
+ attr_writer :gateway_client_version
45
+
28
46
  # --- serialization ---------------------------------------------------------
29
47
 
30
48
  # The DEFAULT serializer base class. A `Resource` registration that does not
@@ -81,10 +99,10 @@ class McpToolkit::Configuration
81
99
 
82
100
  # Looks up + verifies a plaintext bearer token locally, returning a token
83
101
  # object (duck-typed, see below) or nil. This is the authority's
84
- # `McpToken.authenticate(plaintext)` equivalent. Required for the :authority
102
+ # `AccessToken.authenticate(plaintext)` equivalent. Required for the :authority
85
103
  # role; unused by a pure satellite.
86
104
  #
87
- # c.token_authenticator = ->(plaintext) { McpToken.authenticate(plaintext) }
105
+ # c.token_authenticator = ->(plaintext) { AccessToken.authenticate(plaintext) }
88
106
  #
89
107
  # The returned token object must respond to the methods
90
108
  # `McpToolkit::Auth::Authority#introspection_payload` reads (see that module for
@@ -108,6 +126,39 @@ class McpToolkit::Configuration
108
126
  # @return [Integer] session sliding-TTL in seconds.
109
127
  attr_accessor :session_ttl
110
128
 
129
+ # --- rate limiting ---------------------------------------------------------
130
+
131
+ # The built-in per-principal request cap enforced by the authority transport
132
+ # (McpToolkit::Authority::ControllerMethods#mcp_rate_limit!), counted against
133
+ # `cache_store` via McpToolkit::RateLimiter. nil (the default) DISABLES rate
134
+ # limiting entirely, so a pure host is unaffected until it opts in. Set an
135
+ # Integer to cap each principal to that many requests per `rate_limit_window`.
136
+ # The default `mcp_rate_limit!` reads this through the overridable
137
+ # `mcp_rate_limit_max_requests` hook, so a host that keeps the cap in its own
138
+ # constant/model overrides that hook rather than this value.
139
+ #
140
+ # @return [Integer, nil]
141
+ attr_accessor :rate_limit_max_requests
142
+
143
+ # The fixed rate-limit window, in seconds (default 3600 = 1 hour). Ignored
144
+ # while `rate_limit_max_requests` is nil.
145
+ #
146
+ # @return [Integer]
147
+ attr_accessor :rate_limit_window
148
+
149
+ # --- superuser (optional, first-class) -------------------------------------
150
+
151
+ # Optional resolver deciding whether a principal is a SUPERUSER — a cross-tenant
152
+ # caller that may reach `superusers_only!` resources. `->(principal) -> Boolean`.
153
+ # When set, McpToolkit::Authority::Context#superuser? calls it; when nil (the
154
+ # default) the context falls back to duck-typing `principal.superuser?` (false
155
+ # when the principal doesn't respond to it). Superuser is FULLY OPTIONAL: a host
156
+ # with no such concept leaves this nil and flags no `superusers_only!` resource,
157
+ # so no caller is ever a superuser.
158
+ #
159
+ # @return [#call, nil]
160
+ attr_accessor :superuser_resolver
161
+
111
162
  # --- filtering -------------------------------------------------------------
112
163
 
113
164
  # Escapes LIKE wildcards in `matches` / `does_not_match` filter values so they
@@ -118,12 +169,108 @@ class McpToolkit::Configuration
118
169
  # @return [#sanitize_sql_like]
119
170
  attr_accessor :sql_sanitizer
120
171
 
172
+ # NOTE: (all data-path settings below): the list/get executors and the schema
173
+ # builder read the PROCESS-GLOBAL `McpToolkit.config` — a per-instance config
174
+ # bound to a provider affects tool prose only. Configure these globally.
175
+ #
176
+ # How a BARE (non-operator) filter value is interpreted by the list executor:
177
+ #
178
+ # :tokenized (default) — a comma-separated string becomes an IN set, the
179
+ # "null" token (and a JSON null) matches NULL rows, an Array of non-null
180
+ # scalars is an IN set (nil/Hash/nested-Array elements rejected), and an
181
+ # empty string means "no filter".
182
+ # :literal — the value is handed to the WHERE clause verbatim (an op-less
183
+ # Hash is still rejected). This preserves an EXISTING API contract for a
184
+ # host whose pre-gem endpoint matched bare values literally: "a,b" is one
185
+ # literal string, "null" is the literal string, "" matches empty-string
186
+ # rows, an Array (including nil elements) gets the adapter's native IN /
187
+ # OR-IS-NULL handling.
188
+ #
189
+ # Operator conditions ({ op:, value: }) behave identically in both modes.
190
+ #
191
+ # @return [Symbol] :tokenized or :literal
192
+ attr_accessor :bare_filter_value_semantics
193
+
194
+ # Default ordering for a resource whose primary key is NON-numeric (numeric
195
+ # PKs always order by :id):
196
+ #
197
+ # :created_at (default) — ORDER BY created_at, <pk> (chronological pages
198
+ # with a total-order tiebreaker).
199
+ # :primary_key — ORDER BY <pk> only. Preserves an EXISTING API contract for
200
+ # a host whose pre-gem endpoint ordered every list by id.
201
+ #
202
+ # @return [Symbol] :created_at or :primary_key
203
+ attr_accessor :non_numeric_pk_order
204
+
205
+ # Per-column-type overrides for the operator sets advertised by
206
+ # `resource_schema` and enforced by the list executor, merged over
207
+ # McpToolkit::Filtering::OPERATORS_BY_TYPE. Lets a host preserve an EXISTING
208
+ # operator contract exactly — both the schema bytes and which conditions are
209
+ # accepted — e.g. `{ text: %w[eq in], date: %w[eq in] }` for a pre-gem
210
+ # endpoint that never offered comparisons on those types. Empty by default
211
+ # (the gem's own sets apply).
212
+ #
213
+ # @return [Hash{Symbol => Array<String>}]
214
+ attr_reader :filter_operator_overrides
215
+
216
+ # Assigns per-type operator overrides, rejecting any operator the gem cannot
217
+ # safely dispatch. Only McpToolkit::Filtering::AREL_PREDICATIONS map onto an
218
+ # Arel predication that binds/quotes its value; anything else (e.g. "extract")
219
+ # would be public_send to an Arel attribute with the request value passed
220
+ # through VERBATIM — an SQL-injection surface. Fail fast at config time so a
221
+ # typo can never open that door at request time.
222
+ def filter_operator_overrides=(overrides)
223
+ overrides ||= {}
224
+ unless overrides.is_a?(Hash)
225
+ raise ArgumentError,
226
+ "filter_operator_overrides must be a Hash of { column_type => [operator, ...] }, " \
227
+ "got #{overrides.class}"
228
+ end
229
+
230
+ overrides.each do |type, operators|
231
+ unsupported = Array(operators).map(&:to_s) - McpToolkit::Filtering::AREL_PREDICATIONS
232
+ next if unsupported.empty?
233
+
234
+ raise ArgumentError,
235
+ "filter_operator_overrides[#{type.inspect}] has unsupported operator(s): " \
236
+ "#{unsupported.join(", ")}. Allowed: #{McpToolkit::Filtering::AREL_PREDICATIONS.join(", ")}."
237
+ end
238
+
239
+ @filter_operator_overrides = overrides
240
+ end
241
+
242
+ # Caps how many values an IN-set filter may resolve to, and how many operator
243
+ # conditions may be ANDed on a single attribute, so a valid token can't emit
244
+ # an unbounded IN clause / AND-chain (oversized SQL + Arel AST and expensive
245
+ # planning; rate limiting is opt-in via rate_limit_max_requests). Applies to
246
+ # the default :tokenized bare-value semantics and to { op:, value: }
247
+ # conditions. nil disables the cap.
248
+ #
249
+ # @return [Integer, nil]
250
+ attr_accessor :max_filter_values
251
+
252
+ # Caps how many JSON-RPC calls a single POST batch may carry on the authority
253
+ # transport. Rate limiting is per-HTTP-request, so an uncapped batch would let
254
+ # one request fan out unbounded work (N tool executions / N upstream calls)
255
+ # under a single rate-limit tick. nil disables the cap.
256
+ #
257
+ # @return [Integer, nil]
258
+ attr_accessor :max_batch_size
259
+
121
260
  # --- protocol / transport --------------------------------------------------
122
261
 
123
262
  # @return [String, nil] protocol version to pin on the underlying MCP::Server.
124
263
  # nil lets the gem negotiate (recommended). Set only to force an older spec.
125
264
  attr_accessor :protocol_version
126
265
 
266
+ # The protocol versions the hand-rolled AUTHORITY dispatcher
267
+ # (McpToolkit::Dispatcher) negotiates, newest first. `initialize` echoes the
268
+ # requested version when it is in this set, else the first (latest). Defaults to
269
+ # McpToolkit::Protocol::SUPPORTED_VERSIONS; override to pin a host's own set.
270
+ #
271
+ # @return [Array<String>]
272
+ attr_accessor :supported_protocol_versions
273
+
127
274
  # The parent class (as a String, resolved via `constantize`) of the
128
275
  # gem-provided McpToolkit::ServerController that McpToolkit::Engine mounts.
129
276
  # Doorkeeper-style indirection so a satellite mounting the engine can keep
@@ -151,12 +298,131 @@ class McpToolkit::Configuration
151
298
  # @return [McpToolkit::Registry]
152
299
  attr_accessor :registry
153
300
 
301
+ # --- gateway / upstreams ---------------------------------------------------
302
+
303
+ # @return [Integer] HTTP open/read timeout (s) for a gateway's calls to an
304
+ # upstream MCP server (McpToolkit::Gateway::Client).
305
+ attr_accessor :upstream_timeout
306
+ # @return [Integer] TTL (s) for an upstream's cached, namespaced tool list in
307
+ # McpToolkit::Gateway::Aggregator.
308
+ attr_accessor :upstream_list_ttl
309
+
310
+ # The registry of upstream MCP servers this gateway aggregates + proxies to.
311
+ # Each config carries its own (like `registry`), so it resets with a fresh
312
+ # config. Register via the `register_upstream` sugar below or directly on this
313
+ # instance. Empty unless the app registers upstreams, so a non-gateway app is
314
+ # unaffected.
315
+ #
316
+ # @return [McpToolkit::Gateway::UpstreamRegistry]
317
+ attr_reader :upstreams
318
+
319
+ # --- authority hooks -------------------------------------------------------
320
+ #
321
+ # Injection points for the AUTHORITY transport (McpToolkit::Authority::
322
+ # ControllerMethods) so a PURE host drives billing/tenancy from config without
323
+ # subclassing. A host whose logic touches its own models overrides the matching
324
+ # hook METHOD on its McpToolkit::Authority::ServerController subclass instead;
325
+ # then these stay nil. All default to nil (a no-op).
326
+
327
+ # OPTIONAL escape hatch that FULLY REPLACES the built-in limiter: a
328
+ # `->(controller:, principal:)` that renders + halts when over the limit (or
329
+ # sets rate-limit headers when under). When set, `mcp_rate_limit!` delegates to
330
+ # it and the built-in (`rate_limit_max_requests`) is skipped. nil (the default)
331
+ # means the built-in runs instead. Most hosts want the built-in; reach for this
332
+ # only when the counting itself must live in app code.
333
+ #
334
+ # @return [#call, nil]
335
+ attr_accessor :rate_limiter
336
+
337
+ # Records ONE usage event for a single JSON-RPC call (called per batch element).
338
+ # `->(request_data:, account:, principal:, controller:)`. MUST never affect the
339
+ # MCP response. nil = no metering.
340
+ #
341
+ # @return [#call, nil]
342
+ attr_accessor :usage_recorder
343
+
344
+ # Persists accumulated usage after the response (an after_action).
345
+ # `->(controller:)`. MUST never affect the MCP response. nil = no flush.
346
+ #
347
+ # @return [#call, nil]
348
+ attr_accessor :usage_flusher
349
+
350
+ # Builds the opaque payload bound to a session on `initialize`. `->(principal:)`
351
+ # returning a Hash (or nil for none). Lets a host bind e.g.
352
+ # `{ token_id: principal.id }` so a revoked token can kill an in-flight session,
353
+ # WITHOUT overriding the controller's `mcp_session_data`. nil (the default) =>
354
+ # an empty session payload.
355
+ #
356
+ # @return [#call, nil]
357
+ attr_accessor :session_data_builder
358
+
359
+ # The host's tool catalog — the api-agnostic seam. Duck-typed; the dispatcher
360
+ # calls:
361
+ #
362
+ # provider.tool_definitions(context) -> [{ name:, description:, inputSchema: }]
363
+ # provider.find(name) -> a tool object, or nil
364
+ #
365
+ # where a tool object responds to `#required_permissions_scope` (String|nil, the
366
+ # gem's scope gate) and `#call(context:, **arguments)` (returns Hash|String,
367
+ # which the gem wraps into `{ content: [{ type: "text", text: }] }`). See
368
+ # McpToolkit::Tools::AuthorityBase for a base that satisfies this.
369
+ #
370
+ # UNSET (the default), the provider is composed automatically: the generic
371
+ # RegistryToolProvider (bound to this config) first, then every entry of
372
+ # `extra_tool_providers` — so a host that only serves the generic tools plus
373
+ # its own bespoke ones needs no provider plumbing at all. Assign explicitly to
374
+ # take full control of the catalog.
375
+ #
376
+ # @return [#tool_definitions, #find]
377
+ attr_writer :tool_provider
378
+
379
+ def tool_provider
380
+ @tool_provider || composed_tool_provider
381
+ end
382
+
383
+ # Additional tool providers (or bare TOOL classes, auto-wrapped in a
384
+ # SingleToolProvider) composed AFTER the generic Registry-backed tools when
385
+ # `tool_provider` is not explicitly assigned. The registry provider is always
386
+ # first, so a generic tool name resolves to it; extras only answer their own
387
+ # names.
388
+ #
389
+ # config.extra_tool_providers = [MyApp::Tools::AuditLog]
390
+ #
391
+ # @return [Array<#tool_definitions, Class>]
392
+ attr_accessor :extra_tool_providers
393
+
394
+ # --- generic tool naming ---------------------------------------------------
395
+
396
+ # A prefix prepended to the four GENERIC, Registry-backed authority tool names
397
+ # (`resources`, `resource_schema`, `get`, `list`) served by
398
+ # McpToolkit::Authority::RegistryToolProvider. Lets a host NAMESPACE its generic
399
+ # tools — e.g. set `"foo_"` and they advertise (and resolve) as `foo_resources`,
400
+ # `foo_resource_schema`, `foo_get`, `foo_list` — so distinct MCP surfaces don't
401
+ # collide and existing clients keep a stable, host-chosen name. Empty by default,
402
+ # so the tools keep their bare base names. The prefix value is the host's; the gem
403
+ # names no app concept.
404
+ #
405
+ # @return [String]
406
+ attr_accessor :generic_tool_name_prefix
407
+
408
+ # --- diagnostics -----------------------------------------------------------
409
+
410
+ # Optional logger for gateway/session diagnostics. All call sites guard with
411
+ # `logger&.warn` / `logger&.error`, so nil (the default) silences them. A Rails
412
+ # host typically sets this to `Rails.logger`.
413
+ #
414
+ # @return [#warn, #error, nil]
415
+ attr_accessor :logger
416
+
154
417
  # Vendor-neutral defaults; apps override the auth wiring + identity as needed.
155
418
  def initialize
156
419
  @server_name = "mcp-server"
157
420
  @server_version = "1.0.0"
158
421
  @server_instructions = nil
159
422
 
423
+ @gateway_client_name = nil
424
+ @gateway_client_version = nil
425
+
160
426
  @serializer_base = nil # set lazily in #serializer_base to avoid load-order issues
161
427
 
162
428
  @auth_role = :satellite
@@ -169,16 +435,107 @@ class McpToolkit::Configuration
169
435
  @token_authenticator = nil
170
436
 
171
437
  @cache_store = ActiveSupport::Cache::MemoryStore.new
172
- @session_ttl = 3600 # 1 hour
173
-
174
- @sql_sanitizer = McpToolkit::SqlSanitizer.new
438
+ initialize_data_path_defaults
175
439
 
176
440
  @protocol_version = nil
441
+ @supported_protocol_versions = McpToolkit::Protocol::SUPPORTED_VERSIONS
177
442
  @parent_controller = "ActionController::Base"
178
443
  @account_meta_key = "mcp-toolkit/account-id"
179
444
  @account_id_header = "X-MCP-Account-ID"
180
445
 
446
+ initialize_authority_hook_defaults
447
+ @generic_tool_name_prefix = ""
448
+
449
+ @upstream_timeout = 10
450
+ @upstream_list_ttl = 900 # 15 minutes
451
+ @logger = nil
452
+
181
453
  @registry = McpToolkit::Registry.new
454
+ @upstreams = McpToolkit::Gateway::UpstreamRegistry.new
455
+ end
456
+
457
+ # Session-TTL and list-executor defaults: the :tokenized / :created_at
458
+ # data-path semantics (a host preserving a pre-gem contract overrides these —
459
+ # see each accessor's docs).
460
+ def initialize_data_path_defaults
461
+ @session_ttl = 3600 # 1 hour
462
+
463
+ @sql_sanitizer = McpToolkit::SqlSanitizer.new
464
+ @bare_filter_value_semantics = :tokenized
465
+ @non_numeric_pk_order = :created_at
466
+ @filter_operator_overrides = {}
467
+ @max_filter_values = 500
468
+ @max_batch_size = 50
469
+ end
470
+
471
+ # The default authority tool catalog when no explicit `tool_provider` is
472
+ # assigned: the generic Registry-backed tools first (so a generic name always
473
+ # resolves to them; included only when the host registered resources — a pure
474
+ # gateway keeps contributing nothing), then each extra provider — a bare tool
475
+ # CLASS is wrapped in a SingleToolProvider. Built per read (cheap, stateless
476
+ # providers), so a reload that re-assigns `extra_tool_providers` or registers
477
+ # resources takes effect immediately.
478
+ def composed_tool_provider
479
+ providers = []
480
+ providers << McpToolkit::Authority::RegistryToolProvider.new(config: self) if registry.resources.any?
481
+ Array(extra_tool_providers).each do |provider|
482
+ providers << (provider.respond_to?(:tool_definitions) ? provider : McpToolkit::Authority::SingleToolProvider.new(provider))
483
+ end
484
+ return nil if providers.empty?
485
+ return providers.first if providers.one?
486
+
487
+ McpToolkit::Authority::CompositeToolProvider.new(*providers)
488
+ end
489
+ private :composed_tool_provider
490
+
491
+ # The authority transport's injection points all default to nil (a no-op): a
492
+ # pure satellite/gateway never touches them. `rate_limit_window` is the sole
493
+ # non-nil default (the window size only matters once a cap opts in).
494
+ def initialize_authority_hook_defaults
495
+ @rate_limiter = nil
496
+ @usage_recorder = nil
497
+ @usage_flusher = nil
498
+ @session_data_builder = nil
499
+ @tool_provider = nil
500
+ @extra_tool_providers = []
501
+ @rate_limit_max_requests = nil # nil = rate limiting disabled
502
+ @rate_limit_window = 3600 # 1 hour
503
+ @superuser_resolver = nil # nil = duck-type principal.superuser?
504
+ end
505
+
506
+ # Config sugar: register a gateway upstream. Delegates to `upstreams.register`,
507
+ # so a blank url is ignored (an unconfigured upstream is simply absent). Pass
508
+ # `public_tool_list: false` for an upstream whose tool list varies by caller
509
+ # privilege, to opt it out of the shared list cache.
510
+ #
511
+ # c.register_upstream(key: "notifications", url: ENV["NOTIFICATIONS_SERVER_URL"])
512
+ def register_upstream(key:, url:, public_tool_list: true)
513
+ upstreams.register(key:, url:, public_tool_list:)
514
+ end
515
+
516
+ # Declares the gateway's upstreams from an ENV-var map — `{ key => env var
517
+ # name }` — the shape every authority host repeats: resets the registry first
518
+ # (idempotent across code reloads, where the registration typically re-runs in
519
+ # a `to_prepare`), and an upstream whose ENV url is blank is never registered,
520
+ # so an unconfigured environment behaves like no-upstreams. `env` is
521
+ # injectable for tests.
522
+ #
523
+ # config.register_upstreams_from_env("billing" => "BILLING_MCP_URL")
524
+ def register_upstreams_from_env(mapping, env: ENV)
525
+ upstreams.reset!
526
+ mapping.each { |key, env_var| register_upstream(key:, url: env[env_var.to_s]) }
527
+ end
528
+
529
+ # The gateway handshake client name, defaulting to the server identity when the
530
+ # host hasn't split it. Read (not stored) so a `server_name` change before the
531
+ # split is set still flows through.
532
+ def gateway_client_name
533
+ @gateway_client_name || server_name
534
+ end
535
+
536
+ # The gateway handshake client version, defaulting to the server version.
537
+ def gateway_client_version
538
+ @gateway_client_version || server_version
182
539
  end
183
540
 
184
541
  # The serializer base, lazily defaulting to the gem's bundled DSL base. Lazy so
@@ -0,0 +1,248 @@
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
+ # A generic message to the caller: the raw exception text of an UNEXPECTED
46
+ # error (ActiveRecord::StatementInvalid carrying SQL, a NoMethodError naming
47
+ # internal classes, an internal hostname) must not leak. Full detail is in
48
+ # the log line above.
49
+ McpToolkit::Protocol.error_response(
50
+ id: request["id"],
51
+ error: McpToolkit::Protocol::InternalError.new("Internal error")
52
+ )
53
+ end
54
+
55
+ private
56
+
57
+ # Happy path; raises here are turned into JSON-RPC errors by handle_request.
58
+ def dispatch_request(request)
59
+ validate_request!(request)
60
+
61
+ result = dispatch_method(request["method"], request["params"] || {})
62
+
63
+ # JSON-RPC 2.0: notifications (requests without `id`) MUST NOT receive a response.
64
+ return nil unless request.key?("id")
65
+
66
+ McpToolkit::Protocol.success_response(id: request["id"], result:)
67
+ end
68
+
69
+ def validate_request!(request)
70
+ unless request["jsonrpc"] == McpToolkit::Protocol::JSONRPC_VERSION
71
+ raise McpToolkit::Protocol::InvalidRequest, "Missing jsonrpc version"
72
+ end
73
+ raise McpToolkit::Protocol::InvalidRequest, "Missing method" if request["method"].blank?
74
+ end
75
+
76
+ def dispatch_method(method, params)
77
+ case method
78
+ when "initialize"
79
+ handle_initialize(params)
80
+ when "initialized", "notifications/initialized"
81
+ handle_initialized
82
+ when "tools/list"
83
+ handle_tools_list
84
+ when "tools/call"
85
+ handle_tools_call(params)
86
+ when "ping"
87
+ handle_ping
88
+ else
89
+ return handle_upstream_list_changed(method) if upstream_list_changed_notification?(method)
90
+
91
+ raise McpToolkit::Protocol::MethodNotFound, method
92
+ end
93
+ end
94
+
95
+ # Satellites can tell the authority their tool list changed via a
96
+ # `notifications/<app>/tools/list_changed` notification, busting that upstream's
97
+ # cached aggregate. Matches the configured upstream keys only.
98
+ def upstream_list_changed_notification?(method)
99
+ upstream_key_from_notification(method).present?
100
+ end
101
+
102
+ def handle_upstream_list_changed(method)
103
+ McpToolkit::Gateway::Aggregator.new(config:).flush!(upstream_key_from_notification(method))
104
+ {}
105
+ end
106
+
107
+ def upstream_key_from_notification(method)
108
+ match = method.to_s.match(%r{\Anotifications/(?<key>.+)/tools/list_changed\z})
109
+ return nil unless match
110
+
111
+ config.upstreams.find(match[:key]) ? match[:key] : nil
112
+ end
113
+
114
+ def handle_initialize(params)
115
+ requested = params["protocolVersion"].to_s
116
+ versions = config.supported_protocol_versions
117
+ negotiated = versions.include?(requested) ? requested : versions.first
118
+
119
+ result = {
120
+ protocolVersion: negotiated,
121
+ capabilities: {
122
+ # listChanged: true — the aggregated list includes upstream tools, which
123
+ # can change when an upstream is reconfigured or sends a list_changed
124
+ # notification that busts the cached aggregate.
125
+ tools: { listChanged: true }
126
+ },
127
+ serverInfo: {
128
+ name: config.server_name,
129
+ version: config.server_version
130
+ }
131
+ }
132
+
133
+ # `instructions` is advertised on initialize only when configured, matching the
134
+ # SDK-backed satellite server (McpToolkit::Server.build) and the documented
135
+ # contract. Omitted when nil to keep the envelope clean.
136
+ result[:instructions] = config.server_instructions if config.server_instructions
137
+ result
138
+ end
139
+
140
+ def handle_initialized
141
+ {}
142
+ end
143
+
144
+ def handle_ping
145
+ {}
146
+ end
147
+
148
+ def handle_tools_list
149
+ {
150
+ tools: host_tool_definitions +
151
+ McpToolkit::Gateway::Aggregator.new(config:).tool_definitions(bearer_token: context.bearer_token)
152
+ }
153
+ end
154
+
155
+ # The host's own tool definitions, sourced from the injected tool_provider (the
156
+ # api-agnostic seam). `context` lets the provider hide superuser-only tools from
157
+ # a non-superuser caller. A host that registered no provider contributes none.
158
+ def host_tool_definitions
159
+ provider = config.tool_provider
160
+ return [] unless provider
161
+
162
+ provider.tool_definitions(context)
163
+ end
164
+
165
+ def handle_tools_call(params)
166
+ tool_name = params["name"]
167
+ arguments = params["arguments"] || {}
168
+
169
+ upstream = config.upstreams.split_tool_name(tool_name)
170
+ return handle_upstream_tools_call(upstream, arguments) if upstream
171
+
172
+ handle_host_tools_call(tool_name, arguments)
173
+ end
174
+
175
+ def handle_host_tools_call(tool_name, arguments)
176
+ tool = config.tool_provider&.find(tool_name)
177
+ raise McpToolkit::Protocol::MethodNotFound, "Tool not found: #{tool_name}" unless tool
178
+
179
+ ensure_tool_scope!(tool)
180
+
181
+ result = tool.call(context:, **symbolized_arguments(arguments))
182
+
183
+ {
184
+ content: [
185
+ {
186
+ type: "text",
187
+ text: result.is_a?(String) ? result : result.to_json
188
+ }
189
+ ]
190
+ }
191
+ end
192
+
193
+ # JSON gives string keys; a tool's `call(context:, **arguments)` needs symbol
194
+ # keys for the keyword splat. Deep-symbolized so nested argument hashes reach
195
+ # the tool in the same shape a symbol-keyed caller would pass.
196
+ #
197
+ # `context` is stripped: a tool is invoked as `call(context:, **arguments)`, and
198
+ # a splatted keyword OVERRIDES the explicit one, so a caller-supplied `context`
199
+ # argument would replace the gem-resolved Authority::Context with attacker JSON
200
+ # (auth-context injection). `context` is never a legitimate tool argument.
201
+ def symbolized_arguments(arguments)
202
+ arguments.to_h.deep_symbolize_keys.except(:context)
203
+ end
204
+
205
+ def ensure_tool_scope!(tool)
206
+ required_scope = tool.required_permissions_scope
207
+ return if required_scope.blank?
208
+ return if context.principal&.authorized_for_scope?(required_scope)
209
+
210
+ raise McpToolkit::Protocol::InvalidRequest, "This token lacks the #{required_scope.inspect} scope"
211
+ end
212
+
213
+ def handle_upstream_tools_call((app_key, bare_tool_name), arguments)
214
+ McpToolkit::Gateway::Proxy.new(
215
+ app_key:,
216
+ tool_name: bare_tool_name,
217
+ account_id: context.account&.id,
218
+ bearer_token: context.bearer_token,
219
+ config:
220
+ ).call(arguments)
221
+ rescue McpToolkit::Gateway::UnknownUpstream => e
222
+ # The gateway stays transport-agnostic; the dispatcher maps an unknown
223
+ # upstream to its own "method not found".
224
+ raise McpToolkit::Protocol::MethodNotFound, e.message
225
+ rescue McpToolkit::Gateway::UpstreamCallError => e
226
+ raise translate_upstream_call_error(e)
227
+ end
228
+
229
+ # Translates the gateway's transport-agnostic upstream failure into the JSON-RPC
230
+ # error shape — verbatim relay of a satellite JSON-RPC error, else a generic
231
+ # internal error.
232
+ def translate_upstream_call_error(error)
233
+ if error.jsonrpc_error
234
+ McpToolkit::Protocol::Error.new(
235
+ error.jsonrpc_error["message"].to_s,
236
+ code: error.jsonrpc_error["code"] || McpToolkit::Protocol::ErrorCodes::INTERNAL_ERROR,
237
+ data: error.jsonrpc_error["data"]
238
+ )
239
+ else
240
+ # A TRANSPORT failure (no upstream JSON-RPC error): its message embeds the
241
+ # internal upstream host:port (Faraday/Net::HTTP "Failed to open TCP
242
+ # connection to <host>:<port>"). The proxy already logged the detail
243
+ # (Gateway::Proxy#log_proxied_failure), so relay only a generic error —
244
+ # never the internal topology — to the caller.
245
+ McpToolkit::Protocol::InternalError.new("Internal error")
246
+ end
247
+ end
248
+ end