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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +201 -2
- data/README.md +315 -34
- data/config/routes.rb +19 -4
- data/lib/mcp_toolkit/auth/authority.rb +2 -2
- data/lib/mcp_toolkit/authority/composite_tool_provider.rb +32 -0
- data/lib/mcp_toolkit/authority/context.rb +45 -0
- data/lib/mcp_toolkit/authority/controller_methods.rb +408 -0
- data/lib/mcp_toolkit/authority/registry_tool_provider.rb +70 -0
- data/lib/mcp_toolkit/authority/token.rb +124 -0
- data/lib/mcp_toolkit/authority/tools/base.rb +122 -0
- data/lib/mcp_toolkit/authority/tools/get.rb +58 -0
- data/lib/mcp_toolkit/authority/tools/list.rb +84 -0
- data/lib/mcp_toolkit/authority/tools/resource_schema.rb +44 -0
- data/lib/mcp_toolkit/authority/tools/resources.rb +33 -0
- data/lib/mcp_toolkit/authority.rb +24 -0
- data/lib/mcp_toolkit/configuration.rb +205 -2
- data/lib/mcp_toolkit/dispatcher.rb +234 -0
- data/lib/mcp_toolkit/engine.rb +22 -7
- data/lib/mcp_toolkit/engine_controllers.rb +116 -0
- data/lib/mcp_toolkit/gateway/aggregator.rb +122 -0
- data/lib/mcp_toolkit/gateway/client.rb +305 -0
- data/lib/mcp_toolkit/gateway/proxy.rb +70 -0
- data/lib/mcp_toolkit/gateway/unknown_upstream.rb +8 -0
- data/lib/mcp_toolkit/gateway/upstream_call_error.rb +23 -0
- data/lib/mcp_toolkit/gateway/upstream_registry.rb +79 -0
- data/lib/mcp_toolkit/list_executor.rb +17 -0
- data/lib/mcp_toolkit/protocol.rb +106 -0
- data/lib/mcp_toolkit/rate_limiter.rb +73 -0
- data/lib/mcp_toolkit/registry.rb +30 -2
- data/lib/mcp_toolkit/resource.rb +125 -7
- data/lib/mcp_toolkit/resource_schema.rb +14 -1
- data/lib/mcp_toolkit/serializer/base.rb +2 -2
- data/lib/mcp_toolkit/session.rb +17 -9
- data/lib/mcp_toolkit/tools/authority_base.rb +127 -0
- data/lib/mcp_toolkit/transport/controller_methods.rb +2 -2
- data/lib/mcp_toolkit/usage_metering/recorder.rb +95 -0
- data/lib/mcp_toolkit/version.rb +1 -1
- data/lib/mcp_toolkit.rb +16 -3
- metadata +38 -2
- 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
|
data/lib/mcp_toolkit/registry.rb
CHANGED
|
@@ -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`
|
|
63
|
-
#
|
|
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
|
data/lib/mcp_toolkit/resource.rb
CHANGED
|
@@ -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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
148
|
-
#
|
|
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
|
data/lib/mcp_toolkit/session.rb
CHANGED
|
@@ -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
|
-
|
|
23
|
-
return nil unless
|
|
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),
|
|
27
|
-
|
|
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
|
|
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
|
|
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
|
data/lib/mcp_toolkit/version.rb
CHANGED