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,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A fixed-window request counter backing the authority transport's built-in rate
4
+ # limiting (McpToolkit::Authority::ControllerMethods#mcp_rate_limit!). It is
5
+ # storage-agnostic: it counts against the injected `cache_store` (any
6
+ # ActiveSupport::Cache::Store — a shared Rails.cache in production, a MemoryStore
7
+ # in a unit test), so a host enables per-principal throttling by setting
8
+ # `config.rate_limit_max_requests` alone, without hand-rolling a limiter.
9
+ #
10
+ # The window is FIXED, not sliding: every request whose time falls in the same
11
+ # `window`-second bucket shares one counter, keyed by that bucket's start
12
+ # (`window_start`); the entry expires after `window` seconds. The counter is
13
+ # incremented once per call, and the request is allowed while the running count is
14
+ # `<= max_requests`, blocked once it exceeds it (so exactly `max_requests`
15
+ # requests pass per window).
16
+ #
17
+ # result = McpToolkit::RateLimiter.new(
18
+ # key: principal.id, max_requests: 1_000, window: 3_600, cache_store: Rails.cache
19
+ # ).call
20
+ # result.allowed? # => false once the count exceeds max_requests
21
+ # result.limit # => 1_000
22
+ # result.remaining # => max_requests - count, floored at 0
23
+ # result.reset_at # => epoch seconds of the next window boundary
24
+ # result.retry_after # => seconds until reset_at (0 when already past)
25
+ #
26
+ # The cache key is namespaced (`mcp_toolkit:rate_limit:<key>:<window_start>`) so a
27
+ # host's own cache entries never collide with the counter.
28
+ class McpToolkit::RateLimiter
29
+ # The outcome of one #call: the throttling decision plus the values the
30
+ # transport renders into the `X-RateLimit-*` / `Retry-After` headers.
31
+ Result = Struct.new(:allowed, :limit, :remaining, :reset_at, :retry_after, keyword_init: true) do
32
+ def allowed?
33
+ allowed
34
+ end
35
+ end
36
+
37
+ def initialize(key:, max_requests:, cache_store:, window: 3_600, now: Time.now)
38
+ @key = key
39
+ @max_requests = max_requests
40
+ @cache_store = cache_store
41
+ @window = window
42
+ @now = now.to_i
43
+ end
44
+
45
+ # Increments this window's counter and returns the Result. Called once per
46
+ # request by the transport's rate-limit hook.
47
+ def call
48
+ count = @cache_store.increment(cache_key, 1, expires_in: @window) || 1
49
+ allowed = count <= @max_requests
50
+
51
+ Result.new(
52
+ allowed:,
53
+ limit: @max_requests,
54
+ remaining: allowed ? @max_requests - count : 0,
55
+ reset_at:,
56
+ retry_after: [reset_at - @now, 0].max
57
+ )
58
+ end
59
+
60
+ private
61
+
62
+ def window_start
63
+ @now - (@now % @window)
64
+ end
65
+
66
+ def reset_at
67
+ window_start + @window
68
+ end
69
+
70
+ def cache_key
71
+ "mcp_toolkit:rate_limit:#{@key}:#{window_start}"
72
+ end
73
+ end
@@ -13,8 +13,33 @@ class McpToolkit::Registry
13
13
  def initialize
14
14
  @resources = {}
15
15
  @default_required_permissions_scope = nil
16
+ @resource_extension = nil
17
+ @resource_finalizer = nil
16
18
  end
17
19
 
20
+ # A Module MIXED INTO every Resource before its registration block runs, so a host
21
+ # can add its OWN declaration DSL (its "extras") on top of the gem's built-in
22
+ # `model` / `scope` / `serializer` / `filterable` / `superusers_only!` / `note` /
23
+ # `filter`. The host method typically stores into the generic `Resource#extra`
24
+ # bag; the `resource_finalizer` reads it back. nil (the default) mixes in nothing,
25
+ # so a host with no extras is unaffected. Set ONCE in `configure` (not per reload),
26
+ # since `reset!` preserves it.
27
+ #
28
+ # McpToolkit.registry.resource_extension = MyApp::ResourceExtension # adds `dependencies`
29
+ #
30
+ # @return [Module, nil]
31
+ attr_accessor :resource_extension
32
+
33
+ # A callable run against each Resource AFTER its registration block, so a host can
34
+ # derive gem-native fields from its declared extras — e.g. build a `serializer`
35
+ # from the `model` + declared `dependencies`, or a lazy `filterable`. `->(resource)`.
36
+ # nil (the default) is a no-op. This is the hook that lets a host avoid a parallel
37
+ # registration system: it declares resources DIRECTLY against the gem registry and
38
+ # fills the derived pieces here. Set ONCE in `configure`; preserved across `reset!`.
39
+ #
40
+ # @return [#call, nil]
41
+ attr_accessor :resource_finalizer
42
+
18
43
  # Registry-wide DEFAULT required scope, so a satellite declares its scope ONCE
19
44
  # for every resource instead of repeating it per resource:
20
45
  #
@@ -32,7 +57,9 @@ class McpToolkit::Registry
32
57
 
33
58
  def register(name, &)
34
59
  resource = McpToolkit::Resource.new(name)
60
+ resource.extend(@resource_extension) if @resource_extension
35
61
  resource.instance_eval(&)
62
+ @resource_finalizer&.call(resource)
36
63
  @resources[name.to_s] = resource
37
64
  end
38
65
 
@@ -59,8 +86,9 @@ class McpToolkit::Registry
59
86
  end
60
87
 
61
88
  # Clears registered resources for a dev reload (the satellite re-declares them
62
- # in `to_prepare`). The `default_required_permissions_scope` is PRESERVED, since
63
- # it's declared once in `configure` rather than per-reload.
89
+ # in `to_prepare`). The `default_required_permissions_scope`, `resource_extension`
90
+ # and `resource_finalizer` are PRESERVED, since they're declared once in
91
+ # `configure` rather than per-reload.
64
92
  def reset!
65
93
  @resources = {}
66
94
  end
@@ -16,6 +16,20 @@
16
16
  class McpToolkit::Resource
17
17
  class NotConfigured < StandardError; end
18
18
 
19
+ # A resource-specific ("custom") filter: a request-facing key whose value is
20
+ # applied to the relation by an arbitrary host-supplied block, rather than the
21
+ # generic equality/operator allowlist. The block is api-agnostic — it receives
22
+ # the already-scoped relation and the raw request value and returns a narrowed
23
+ # relation — so a host can express a relational or otherwise non-column filter
24
+ # (e.g. "only rows whose associated booking is in this rental") without the gem
25
+ # knowing anything about the query. `type`/`description` are surfaced by
26
+ # resource_schema so a client can discover the filter.
27
+ CustomFilter = Struct.new(:name, :type, :description, :applier, keyword_init: true)
28
+
29
+ # Sentinel distinguishing `extra(:key)` (read) from `extra(:key, nil)` (write nil).
30
+ UNSET = Object.new
31
+ private_constant :UNSET
32
+
19
33
  attr_reader :name
20
34
 
21
35
  def initialize(name)
@@ -24,10 +38,32 @@ class McpToolkit::Resource
24
38
  @serializer = nil
25
39
  @scope_block = nil
26
40
  @description = nil
41
+ @note = nil
42
+ @superusers_only = false
27
43
  @filterable = {}
44
+ @filterable_source = nil
45
+ @custom_filters = {}
28
46
  @required_permissions_scope = nil
47
+ @extras = {}
48
+ end
49
+
50
+ # A generic, api-agnostic metadata bag for host-defined "extras" — declarations
51
+ # the gem does not model itself (e.g. an app's ORM dependency list used to build a
52
+ # serializer). It is the storage behind `config.registry.resource_extension` (the
53
+ # host DSL that writes extras inside a registration block) and
54
+ # `config.registry.resource_finalizer` (which reads them back to derive gem-native
55
+ # fields such as `serializer` / `filterable`). Write with `extra(:key, value)`;
56
+ # read with `extra(:key)` (nil when unset). The gem never inspects the values.
57
+ def extra(key, value = UNSET)
58
+ return @extras[key] if value.equal?(UNSET)
59
+
60
+ @extras[key] = value
29
61
  end
30
62
 
63
+ # The full host-extras bag (symbol/whatever key => value). Read-only view for a
64
+ # resource_finalizer that wants to iterate every declared extra.
65
+ attr_reader :extras
66
+
31
67
  def model(klass = nil)
32
68
  @model = klass if klass
33
69
  @model
@@ -48,6 +84,59 @@ class McpToolkit::Resource
48
84
  @description
49
85
  end
50
86
 
87
+ # Free-form usage caveat surfaced by the `resources` / `resource_schema` tools,
88
+ # e.g. to flag a resource as internal-debugging-only and not to be interpreted
89
+ # without domain knowledge. Read with no arg. api-agnostic passthrough string.
90
+ def note(text = nil)
91
+ @note = text if text
92
+ @note
93
+ end
94
+
95
+ # Restricts this resource to superuser (cross-tenant) callers on the AUTHORITY
96
+ # path: an authority tool refuses `get` / `list` / `resource_schema` for a
97
+ # non-superuser and HIDES the resource from `resources` discovery. Declared in a
98
+ # resource's registration block:
99
+ #
100
+ # McpToolkit.registry.register(:audit_events) do
101
+ # superusers_only!
102
+ # ...
103
+ # end
104
+ #
105
+ # Generic and api-agnostic — the gem never names an app concept; the caller's
106
+ # superuser-ness is derived by the Authority::Context off the principal.
107
+ def superusers_only!
108
+ @superusers_only = true
109
+ end
110
+
111
+ # Whether this resource is restricted to superuser callers (default false).
112
+ def superusers_only?
113
+ @superusers_only
114
+ end
115
+
116
+ # Declares a resource-specific ("custom") filter: a request-facing `name` whose
117
+ # value is applied to the already-scoped relation by the given block. Unlike the
118
+ # `filterable` allowlist (generic equality/operator filters on a declared
119
+ # column), a custom filter runs ARBITRARY host logic, so a host can express a
120
+ # relational filter the gem could not derive:
121
+ #
122
+ # filter :rental_id, type: :integer, description: "Only rows for this rental" do |relation, value|
123
+ # relation.joins(:booking).where(bookings: { rental_id: value })
124
+ # end
125
+ #
126
+ # The block receives `(relation, value)` and MUST return a relation (narrowing
127
+ # only). `type` / `description` are metadata surfaced by `resource_schema`. The
128
+ # value arrives from a TOP-LEVEL request param keyed by `name` (see ListExecutor),
129
+ # applied BEFORE the allowlist `filterable` filters. api-agnostic: the gem stores
130
+ # and calls the block without inspecting it.
131
+ def filter(name, type:, description:, &applier)
132
+ @custom_filters[name.to_sym] = CustomFilter.new(name: name.to_sym, type:, description:, applier:)
133
+ end
134
+
135
+ # Request-facing custom-filter key (symbol) => CustomFilter. Consumed by the list
136
+ # executor (which applies each block whose key is present in the request params)
137
+ # and by resource_schema (which surfaces each filter's type/description).
138
+ attr_reader :custom_filters
139
+
51
140
  # The OAuth-style scope a token MUST carry to reach this resource via the
52
141
  # generic tools (e.g. "notifications__read"). Declared explicitly per resource:
53
142
  #
@@ -80,24 +169,35 @@ class McpToolkit::Resource
80
169
  #
81
170
  # Unmapped/unknown keys are rejected by the list executor, never silently
82
171
  # dropped, so a typo surfaces as actionable feedback.
83
- def filterable(mapping = nil)
84
- return @filterable if mapping.nil?
85
-
86
- mapping.each do |request_key, column|
87
- @filterable[request_key.to_sym] = column.to_sym
172
+ # Accepts a Hash (merged now) OR a callable returning a Hash (resolved LAZILY on
173
+ # first read). The lazy form lets a host derive the map from something that must
174
+ # NOT be touched at registration/boot time — e.g. a DB-backed column list
175
+ # (`Model.column_names`): registration typically runs inside an initializer's
176
+ # `to_prepare`, before the database may exist (e.g. CI's `db:create`), so hitting
177
+ # the DB there aborts boot. A callable source is invoked at most once — on the
178
+ # first read (a tool call, when the DB is present) — then memoized.
179
+ def filterable(mapping = nil, &block)
180
+ source = block || mapping
181
+ return filterable_columns if source.nil?
182
+
183
+ if source.respond_to?(:call)
184
+ @filterable_source = source
185
+ else
186
+ merge_filterable!(source)
88
187
  end
89
- @filterable
188
+ self
90
189
  end
91
190
 
92
191
  # Request-facing filter keys (symbols, sorted) this resource can be filtered
93
192
  # by. Surfaced via the `resource_schema` tool.
94
193
  def filterable_keys
95
- @filterable.keys.sort
194
+ filterable_columns.keys.sort
96
195
  end
97
196
 
98
197
  # Request-facing filter key (symbol) => backing column (symbol). Consumed by
99
198
  # the list executor to build the WHERE clause.
100
199
  def filterable_columns
200
+ resolve_filterable_source!
101
201
  @filterable
102
202
  end
103
203
 
@@ -126,4 +226,22 @@ class McpToolkit::Resource
126
226
 
127
227
  serializer.declared_associations
128
228
  end
229
+
230
+ private
231
+
232
+ # Resolves a lazily-provided filterable source (a callable) exactly once — on the
233
+ # first `filterable_columns` / `filterable_keys` read — then drops it, so later
234
+ # reads are pure Hash access. This is what keeps a DB-derived map (e.g.
235
+ # `Model.column_names`) out of registration/boot time.
236
+ def resolve_filterable_source!
237
+ return unless @filterable_source
238
+
239
+ source = @filterable_source
240
+ @filterable_source = nil
241
+ merge_filterable!(source.call || {})
242
+ end
243
+
244
+ def merge_filterable!(mapping)
245
+ mapping.each { |request_key, column| @filterable[request_key.to_sym] = column.to_sym }
246
+ end
129
247
  end
@@ -38,6 +38,7 @@ class McpToolkit::ResourceSchema
38
38
  {
39
39
  name: resource.name,
40
40
  description: resource.description,
41
+ note: resource.note,
41
42
  attributes:,
42
43
  relationships:,
43
44
  standard_filters: STANDARD_FILTERS,
@@ -59,10 +60,22 @@ class McpToolkit::ResourceSchema
59
60
  name:,
60
61
  type: type ? type.to_s : COMPUTED_TYPE,
61
62
  format: type ? TYPE_FORMATS[type] : nil,
62
- filterable: filterable_column_for(name).present?
63
+ filterable: filterable_column_for(name).present?,
64
+ operators: operators_for(name)
63
65
  }.compact
64
66
  end
65
67
 
68
+ # The filter operators an attribute accepts, derived from the backing column's
69
+ # type via McpToolkit::Filtering::OPERATORS_BY_TYPE. `[]` for a non-filterable
70
+ # attribute (or one whose column type has no operator set) — self-describing so
71
+ # a client knows exactly which `{ op:, value: }` conditions `list` will accept.
72
+ def operators_for(attribute_name)
73
+ pair = filterable_column_for(attribute_name)
74
+ return [] unless pair
75
+
76
+ McpToolkit::Filtering::OPERATORS_BY_TYPE.fetch(column_type(pair.last), [])
77
+ end
78
+
66
79
  # Per-attribute equality filters this resource accepts on the `list` tool's
67
80
  # `filter` argument. Each entry is the request-facing key, the backing column
68
81
  # it matches against, and the column's type — self-describing so an MCP client
@@ -144,8 +144,8 @@ class McpToolkit::Serializer::Base
144
144
 
145
145
  # Infer the serialized model from the serializer class name by stripping a
146
146
  # trailing "Serializer" and the host namespace, e.g.
147
- # Mcp::NotificationSerializer -> Notification
148
- # Mcp::PushNotifications::FilterSerializer -> PushNotifications::Filter
147
+ # Api::WidgetSerializer -> Widget
148
+ # Api::ScheduledWidgets::FilterSerializer -> ScheduledWidgets::Filter
149
149
  # Subclasses whose name doesn't follow the convention set `model_class`.
150
150
  def self.model_class
151
151
  @model_class ||= begin
@@ -7,24 +7,31 @@
7
7
  # Cache-backed (rather than the gem's in-process StreamableHTTPTransport) so
8
8
  # sessions survive across Puma workers and interoperate with a gateway's client.
9
9
  # The cache store + TTL come from McpToolkit.config.
10
+ #
11
+ # A session carries an opaque `data` hash the transport can attach at creation
12
+ # (e.g. `{ token_id: ... }`) so an AUTHORITY can bind a session to a token id —
13
+ # the property that lets a revoked token kill an in-flight session. The gem does
14
+ # NOT interpret `data` (it never re-resolves a token; that's the consumer's auth
15
+ # concern): it stores it, round-trips it, and exposes it via `#data`.
10
16
  class McpToolkit::Session
11
17
  CACHE_KEY_PREFIX = "mcp_toolkit:session:"
12
18
 
13
- def self.create!(config: McpToolkit.config)
19
+ def self.create!(data: {}, config: McpToolkit.config)
14
20
  id = SecureRandom.uuid
15
- config.cache_store.write(cache_key(id), { created_at: Time.now.to_i }, expires_in: config.session_ttl)
16
- new(id:)
21
+ config.cache_store.write(cache_key(id), { created_at: Time.now.to_i, data: }, expires_in: config.session_ttl)
22
+ new(id:, data:)
17
23
  end
18
24
 
19
25
  def self.find(id, config: McpToolkit.config)
20
26
  return nil if id.to_s.empty?
21
27
 
22
- data = config.cache_store.read(cache_key(id))
23
- return nil unless data
28
+ stored = config.cache_store.read(cache_key(id))
29
+ return nil unless stored
24
30
 
25
31
  # Sliding expiry: bump TTL on every successful lookup.
26
- config.cache_store.write(cache_key(id), data, expires_in: config.session_ttl)
27
- new(id:)
32
+ config.cache_store.write(cache_key(id), stored, expires_in: config.session_ttl)
33
+ # `data` defaults to {} for legacy rows written before the payload existed.
34
+ new(id:, data: stored[:data] || {})
28
35
  end
29
36
 
30
37
  def self.delete(id, config: McpToolkit.config)
@@ -38,9 +45,10 @@ class McpToolkit::Session
38
45
  end
39
46
  private_class_method :cache_key
40
47
 
41
- attr_reader :id
48
+ attr_reader :id, :data
42
49
 
43
- def initialize(id:)
50
+ def initialize(id:, data: {})
44
51
  @id = id
52
+ @data = data || {}
45
53
  end
46
54
  end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional base class for a HOST's own tools served by the authority dispatcher
4
+ # (McpToolkit::Dispatcher). A host tool MAY subclass this, or may be any object
5
+ # satisfying the duck-typed tool contract the dispatcher calls:
6
+ #
7
+ # tool.required_permissions_scope -> String | nil (gem's scope gate)
8
+ # tool.call(context:, **arguments) -> Hash | String (gem wraps into
9
+ # { content: [...] })
10
+ #
11
+ # The dispatcher treats the object returned by `provider.find(name)` as the tool;
12
+ # an AuthorityBase SUBCLASS satisfies the contract as CLASS methods (the class
13
+ # `.call` instantiates, runs, and error-maps a single invocation).
14
+ #
15
+ # What this base adds over hand-rolling the contract:
16
+ # * a class DSL (`tool_name` / `description` / `input_schema` /
17
+ # `required_permissions_scope` / `definition`) mirroring the tool-definition
18
+ # shape `tools/list` returns;
19
+ # * per-request accessors (`account` / `principal` / `bearer_token` /
20
+ # `superuser?`) read from the injected Authority::Context;
21
+ # * `ensure_resource_accessible!` to gate a superuser-only resource;
22
+ # * error mapping — an ArgumentError (e.g. a missing required kwarg) becomes an
23
+ # InvalidParams, any other StandardError an InternalError, while a
24
+ # deliberately-raised McpToolkit::Protocol::Error passes through with its own
25
+ # code.
26
+ #
27
+ # The gem NEVER references a host's API layer, serializers, or resource catalog —
28
+ # all of that lives behind the host's `#call`.
29
+ class McpToolkit::Tools::AuthorityBase
30
+ class << self
31
+ attr_reader :_tool_name, :_description, :_input_schema
32
+
33
+ def tool_name(name = nil)
34
+ if name
35
+ @_tool_name = name.to_s
36
+ else
37
+ @_tool_name || self.name.to_s.demodulize.underscore.gsub(/_tool$/, "")
38
+ end
39
+ end
40
+
41
+ def description(desc = nil)
42
+ @_description = desc if desc
43
+ @_description
44
+ end
45
+
46
+ def input_schema(&block)
47
+ @_input_schema = yield if block
48
+ @_input_schema || { type: "object", properties: {} }
49
+ end
50
+
51
+ # OAuth-style scope (`<app>__<action>`) a token must carry to call this tool,
52
+ # enforced by the dispatcher before the tool runs. Defaults to nil (no scope
53
+ # required). NOT inherited — a subclass that doesn't declare its own scope is
54
+ # unscoped, even if an ancestor declared one.
55
+ def required_permissions_scope(scope = nil)
56
+ @_required_permissions_scope = scope.to_s if scope
57
+ @_required_permissions_scope
58
+ end
59
+
60
+ def definition
61
+ {
62
+ name: tool_name,
63
+ description: _description,
64
+ inputSchema: _input_schema || { type: "object", properties: {} }
65
+ }
66
+ end
67
+
68
+ # The dispatcher's entry point: build an instance bound to this request's
69
+ # context and run it, mapping tool-level errors to protocol errors.
70
+ def call(context:, **arguments)
71
+ new(context:).execute(**arguments)
72
+ end
73
+ end
74
+
75
+ attr_reader :context
76
+
77
+ def initialize(context:)
78
+ @context = context
79
+ end
80
+
81
+ def account
82
+ context.account
83
+ end
84
+
85
+ def principal
86
+ context.principal
87
+ end
88
+
89
+ def bearer_token
90
+ context.bearer_token
91
+ end
92
+
93
+ # Whether the caller is a superuser, per the Context (which duck-types it off
94
+ # the principal). Used to gate resources/tools that expose cross-tenant data.
95
+ def superuser?
96
+ context.superuser?
97
+ end
98
+
99
+ # Guards a resource flagged `superusers_only?`: a non-superuser caller is
100
+ # refused. No-op for unrestricted resources.
101
+ def ensure_resource_accessible!(resource)
102
+ return unless resource.superusers_only?
103
+ return if superuser?
104
+
105
+ raise McpToolkit::Protocol::InvalidRequest, "#{resource.name} is restricted to superuser (user-scoped) MCP tokens"
106
+ end
107
+
108
+ # Runs the tool's business logic (the subclass's `#call`) with error mapping.
109
+ # Arrives with symbol-keyed arguments from the dispatcher.
110
+ def execute(**arguments)
111
+ call(**arguments)
112
+ rescue McpToolkit::Protocol::Error
113
+ # A deliberately-raised protocol error carries its own JSON-RPC code
114
+ # (e.g. InvalidParams); let it bubble untouched so the client sees it.
115
+ raise
116
+ rescue ArgumentError => e
117
+ raise McpToolkit::Protocol::InvalidParams, e.message
118
+ rescue StandardError => e
119
+ raise McpToolkit::Protocol::InternalError, e.message
120
+ end
121
+
122
+ # The subclass implements its business logic here, receiving the tool arguments
123
+ # as keywords and returning a Hash or String.
124
+ def call(**_arguments)
125
+ raise NotImplementedError, "#{self.class} must implement #call"
126
+ end
127
+ end
@@ -5,7 +5,7 @@ require "logger"
5
5
  # The MCP Streamable-HTTP transport, provided as an includable concern. An
6
6
  # app's controller includes this to get the full transport with no per-app code:
7
7
  #
8
- # class Mcp::ServerController < ApplicationController
8
+ # class McpController < ApplicationController
9
9
  # include McpToolkit::Transport::ControllerMethods
10
10
  # end
11
11
  #
@@ -44,7 +44,7 @@ require "logger"
44
44
  #
45
45
  # CSRF: the concern disables forgery protection (this is a token-authenticated
46
46
  # JSON API). Inherit from ActionController::Base (not ::API) if your app's
47
- # controller stack needs helper_method, as bsa-notifications does.
47
+ # controller stack needs helper_method (as some host controller stacks require).
48
48
  module McpToolkit::Transport::ControllerMethods
49
49
  extend ActiveSupport::Concern
50
50
 
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generic, api-agnostic usage metering for the AUTHORITY transport.
4
+ #
5
+ # Wired to the authority controller's billing hooks purely through config, so a
6
+ # host meters MCP traffic WITHOUT subclassing or customizing the controller:
7
+ #
8
+ # meter = McpToolkit::UsageMetering::Recorder.new(
9
+ # event_builder: ->(request_data:, params:, arguments:, scrubbed_arguments:, account:, principal:) { {...} },
10
+ # sink: ->(events) { MyLedger.insert_all(events) },
11
+ # parameter_filter: ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters),
12
+ # logger: Rails.logger,
13
+ # error_reporter: ->(e) { Sentry.capture_exception(e) }
14
+ # )
15
+ # config.usage_recorder = meter.method(:record) # per JSON-RPC call
16
+ # config.usage_flusher = meter.method(:flush) # after the response
17
+ #
18
+ # One event is accumulated per BILLABLE JSON-RPC call (default: `tools/call`) and
19
+ # all of a request's events are flushed together after the response. Because the
20
+ # authority transport re-resolves the account per batch element and calls `record`
21
+ # per element, a mixed-account batch meters each call against its own account.
22
+ #
23
+ # Two invariants:
24
+ # * Metering NEVER affects the MCP response — every error here is logged (via
25
+ # `logger`) and reported (via `error_reporter`), then swallowed.
26
+ # * The raw arguments are scrubbed through `parameter_filter` BEFORE they reach
27
+ # the event, so a filtered key (e.g. a token) never leaves this object.
28
+ #
29
+ # The `event_builder` returns ONE ledger row's attributes (a Hash) for a call, or
30
+ # nil to skip it; the gem stays app-agnostic by never naming the row's columns. The
31
+ # `sink` persists the accumulated array in one shot.
32
+ #
33
+ # Per-request state (the accumulation buffer) lives on the Rack request env, so the
34
+ # Recorder itself is stateless and safe to share across requests/threads.
35
+ class McpToolkit::UsageMetering::Recorder
36
+ DEFAULT_BILLABLE_METHODS = %w[tools/call].freeze
37
+ BUFFER_ENV_KEY = "mcp_toolkit.usage_events"
38
+
39
+ def initialize(event_builder:, sink:, parameter_filter: nil,
40
+ billable_methods: DEFAULT_BILLABLE_METHODS, logger: nil, error_reporter: nil)
41
+ @event_builder = event_builder
42
+ @sink = sink
43
+ @parameter_filter = parameter_filter
44
+ @billable_methods = billable_methods
45
+ @logger = logger
46
+ @error_reporter = error_reporter
47
+ end
48
+
49
+ # `config.usage_recorder` target. Accumulates one event for a billable call onto
50
+ # the current request's buffer. Non-billable methods (ping, initialize,
51
+ # tools/list, ...) are ignored; a nil event from the builder is skipped.
52
+ def record(request_data:, account:, principal:, controller:)
53
+ return unless request_data.is_a?(Hash)
54
+ return unless @billable_methods.include?(request_data["method"])
55
+
56
+ params = request_data["params"].to_h
57
+ arguments = params["arguments"].to_h
58
+ event = @event_builder.call(
59
+ request_data:, params:, arguments:,
60
+ scrubbed_arguments: scrub(arguments), account:, principal:
61
+ )
62
+ buffer_for(controller) << event unless event.nil?
63
+ rescue StandardError => e
64
+ report("failed to accumulate event", e)
65
+ end
66
+
67
+ # `config.usage_flusher` target. Persists the request's accumulated events via the
68
+ # sink in one shot. No-op when nothing was accumulated.
69
+ def flush(controller:)
70
+ events = buffer_for(controller)
71
+ return if events.empty?
72
+
73
+ @sink.call(events)
74
+ rescue StandardError => e
75
+ report("failed to flush #{events&.size} event(s)", e)
76
+ end
77
+
78
+ private
79
+
80
+ def scrub(arguments)
81
+ hash = arguments.to_h
82
+ @parameter_filter ? @parameter_filter.filter(hash) : hash
83
+ end
84
+
85
+ # Per-request accumulation buffer, stored on the Rack env so the Recorder holds
86
+ # no per-request state of its own (thread-safe to share across requests).
87
+ def buffer_for(controller)
88
+ controller.request.env[BUFFER_ENV_KEY] ||= []
89
+ end
90
+
91
+ def report(message, error)
92
+ @logger&.warn("MCP usage tracking: #{message}: #{error.message}")
93
+ @error_reporter&.call(error)
94
+ end
95
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module McpToolkit
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end