mcp_toolkit 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -2
  3. data/README.md +315 -34
  4. data/config/routes.rb +20 -5
  5. data/lib/mcp_toolkit/auth/authority.rb +2 -2
  6. data/lib/mcp_toolkit/authority/composite_tool_provider.rb +32 -0
  7. data/lib/mcp_toolkit/authority/context.rb +45 -0
  8. data/lib/mcp_toolkit/authority/controller_methods.rb +423 -0
  9. data/lib/mcp_toolkit/authority/registry_tool_provider.rb +69 -0
  10. data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
  11. data/lib/mcp_toolkit/authority/token.rb +124 -0
  12. data/lib/mcp_toolkit/authority/tools/base.rb +150 -0
  13. data/lib/mcp_toolkit/authority/tools/get.rb +59 -0
  14. data/lib/mcp_toolkit/authority/tools/list.rb +132 -0
  15. data/lib/mcp_toolkit/authority/tools/resource_schema.rb +52 -0
  16. data/lib/mcp_toolkit/authority/tools/resources.rb +55 -0
  17. data/lib/mcp_toolkit/authority.rb +24 -0
  18. data/lib/mcp_toolkit/configuration.rb +362 -5
  19. data/lib/mcp_toolkit/dispatcher.rb +248 -0
  20. data/lib/mcp_toolkit/engine.rb +26 -8
  21. data/lib/mcp_toolkit/engine_controllers.rb +124 -0
  22. data/lib/mcp_toolkit/filtering.rb +168 -23
  23. data/lib/mcp_toolkit/gateway/aggregator.rb +142 -0
  24. data/lib/mcp_toolkit/gateway/client.rb +305 -0
  25. data/lib/mcp_toolkit/gateway/proxy.rb +70 -0
  26. data/lib/mcp_toolkit/gateway/unknown_upstream.rb +8 -0
  27. data/lib/mcp_toolkit/gateway/upstream_call_error.rb +23 -0
  28. data/lib/mcp_toolkit/gateway/upstream_registry.rb +79 -0
  29. data/lib/mcp_toolkit/list_executor.rb +65 -4
  30. data/lib/mcp_toolkit/protocol.rb +106 -0
  31. data/lib/mcp_toolkit/rate_limiter.rb +73 -0
  32. data/lib/mcp_toolkit/registry.rb +30 -2
  33. data/lib/mcp_toolkit/resource.rb +173 -6
  34. data/lib/mcp_toolkit/resource_schema.rb +135 -13
  35. data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
  36. data/lib/mcp_toolkit/serializer/base.rb +2 -2
  37. data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
  38. data/lib/mcp_toolkit/session.rb +18 -10
  39. data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
  40. data/lib/mcp_toolkit/tools/authority_base.rb +148 -0
  41. data/lib/mcp_toolkit/tools/base.rb +23 -3
  42. data/lib/mcp_toolkit/tools/get.rb +1 -1
  43. data/lib/mcp_toolkit/tools/list.rb +27 -9
  44. data/lib/mcp_toolkit/tools/resource_schema.rb +12 -3
  45. data/lib/mcp_toolkit/tools/resources.rb +30 -7
  46. data/lib/mcp_toolkit/transport/controller_methods.rb +2 -2
  47. data/lib/mcp_toolkit/usage_metering/recorder.rb +121 -0
  48. data/lib/mcp_toolkit/version.rb +1 -1
  49. data/lib/mcp_toolkit.rb +16 -3
  50. metadata +42 -2
  51. data/app/controllers/mcp_toolkit/server_controller.rb +0 -19
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ # MCP JSON-RPC protocol constants + helpers for the hand-rolled AUTHORITY
4
+ # dispatcher (McpToolkit::Dispatcher). Based on the Model Context Protocol
5
+ # specification.
6
+ #
7
+ # This is the wire vocabulary of a first-party MCP endpoint that serves its own
8
+ # tools (and, as a gateway, aggregates upstream ones) WITHOUT the official `mcp`
9
+ # SDK in the request path. The SDK-backed satellite path
10
+ # (McpToolkit::Server.build) does not use this module; the two dispatch
11
+ # front-ends coexist by design.
12
+ #
13
+ # The error codes, the success/error response envelopes, and the
14
+ # version-negotiation constants here are the BYTE contract a monetized authority
15
+ # endpoint depends on, so they are kept fixed. `SUPPORTED_VERSIONS` is the module
16
+ # default; a host negotiates against `config.supported_protocol_versions`
17
+ # (defaulting to the same list) so the set is overridable without editing this
18
+ # file.
19
+ module McpToolkit::Protocol
20
+ # Protocol versions the server supports, newest first. The version returned in
21
+ # the `initialize` response is the requested version (if supported) or the
22
+ # latest the server supports, per the MCP spec's version-negotiation rules.
23
+ SUPPORTED_VERSIONS = %w[2025-06-18 2025-03-26 2024-11-05].freeze
24
+ LATEST_VERSION = SUPPORTED_VERSIONS.first
25
+ # Kept for backwards compatibility; prefer LATEST_VERSION going forward.
26
+ VERSION = LATEST_VERSION
27
+
28
+ JSONRPC_VERSION = "2.0"
29
+
30
+ # Error codes per JSON-RPC 2.0 spec.
31
+ module ErrorCodes
32
+ PARSE_ERROR = -32_700
33
+ INVALID_REQUEST = -32_600
34
+ METHOD_NOT_FOUND = -32_601
35
+ INVALID_PARAMS = -32_602
36
+ INTERNAL_ERROR = -32_603
37
+ end
38
+
39
+ # Base protocol error. `code`/`data` land verbatim in the JSON-RPC `error`
40
+ # object via `#to_h`; the dispatcher turns a raised Error into a top-level
41
+ # JSON-RPC error response (the envelope a client sees for a bad tool arg or an
42
+ # unknown method).
43
+ class Error < StandardError
44
+ attr_reader :code, :data
45
+
46
+ def initialize(message, code:, data: nil)
47
+ super(message)
48
+ @code = code
49
+ @data = data
50
+ end
51
+
52
+ def to_h
53
+ error = { code:, message: }
54
+ error[:data] = data if data
55
+ error
56
+ end
57
+ end
58
+
59
+ class ParseError < Error
60
+ def initialize(message = "Parse error", data: nil)
61
+ super(message, code: ErrorCodes::PARSE_ERROR, data:)
62
+ end
63
+ end
64
+
65
+ class InvalidRequest < Error
66
+ def initialize(message = "Invalid request", data: nil)
67
+ super(message, code: ErrorCodes::INVALID_REQUEST, data:)
68
+ end
69
+ end
70
+
71
+ class MethodNotFound < Error
72
+ def initialize(method_name, data: nil)
73
+ super("Method not found: #{method_name}", code: ErrorCodes::METHOD_NOT_FOUND, data:)
74
+ end
75
+ end
76
+
77
+ class InvalidParams < Error
78
+ def initialize(message = "Invalid params", data: nil)
79
+ super(message, code: ErrorCodes::INVALID_PARAMS, data:)
80
+ end
81
+ end
82
+
83
+ class InternalError < Error
84
+ def initialize(message = "Internal error", data: nil)
85
+ super(message, code: ErrorCodes::INTERNAL_ERROR, data:)
86
+ end
87
+ end
88
+
89
+ module_function
90
+
91
+ def success_response(id:, result:)
92
+ {
93
+ jsonrpc: JSONRPC_VERSION,
94
+ id:,
95
+ result:
96
+ }
97
+ end
98
+
99
+ def error_response(id:, error:)
100
+ {
101
+ jsonrpc: JSONRPC_VERSION,
102
+ id:,
103
+ error: error.is_a?(Error) ? error.to_h : error
104
+ }
105
+ end
106
+ end
@@ -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,34 @@ 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
+ @filter_requirements = {}
46
+ @filter_requirements_source = nil
47
+ @custom_filters = {}
28
48
  @required_permissions_scope = nil
49
+ @extras = {}
50
+ end
51
+
52
+ # A generic, api-agnostic metadata bag for host-defined "extras" — declarations
53
+ # the gem does not model itself (e.g. an app's ORM dependency list used to build a
54
+ # serializer). It is the storage behind `config.registry.resource_extension` (the
55
+ # host DSL that writes extras inside a registration block) and
56
+ # `config.registry.resource_finalizer` (which reads them back to derive gem-native
57
+ # fields such as `serializer` / `filterable`). Write with `extra(:key, value)`;
58
+ # read with `extra(:key)` (nil when unset). The gem never inspects the values.
59
+ def extra(key, value = UNSET)
60
+ return @extras[key] if value.equal?(UNSET)
61
+
62
+ @extras[key] = value
29
63
  end
30
64
 
65
+ # The full host-extras bag (symbol/whatever key => value). Read-only view for a
66
+ # resource_finalizer that wants to iterate every declared extra.
67
+ attr_reader :extras
68
+
31
69
  def model(klass = nil)
32
70
  @model = klass if klass
33
71
  @model
@@ -48,6 +86,59 @@ class McpToolkit::Resource
48
86
  @description
49
87
  end
50
88
 
89
+ # Free-form usage caveat surfaced by the `resources` / `resource_schema` tools,
90
+ # e.g. to flag a resource as internal-debugging-only and not to be interpreted
91
+ # without domain knowledge. Read with no arg. api-agnostic passthrough string.
92
+ def note(text = nil)
93
+ @note = text if text
94
+ @note
95
+ end
96
+
97
+ # Restricts this resource to superuser (cross-tenant) callers on the AUTHORITY
98
+ # path: an authority tool refuses `get` / `list` / `resource_schema` for a
99
+ # non-superuser and HIDES the resource from `resources` discovery. Declared in a
100
+ # resource's registration block:
101
+ #
102
+ # McpToolkit.registry.register(:audit_events) do
103
+ # superusers_only!
104
+ # ...
105
+ # end
106
+ #
107
+ # Generic and api-agnostic — the gem never names an app concept; the caller's
108
+ # superuser-ness is derived by the Authority::Context off the principal.
109
+ def superusers_only!
110
+ @superusers_only = true
111
+ end
112
+
113
+ # Whether this resource is restricted to superuser callers (default false).
114
+ def superusers_only?
115
+ @superusers_only
116
+ end
117
+
118
+ # Declares a resource-specific ("custom") filter: a request-facing `name` whose
119
+ # value is applied to the already-scoped relation by the given block. Unlike the
120
+ # `filterable` allowlist (generic equality/operator filters on a declared
121
+ # column), a custom filter runs ARBITRARY host logic, so a host can express a
122
+ # relational filter the gem could not derive:
123
+ #
124
+ # filter :rental_id, type: :integer, description: "Only rows for this rental" do |relation, value|
125
+ # relation.joins(:booking).where(bookings: { rental_id: value })
126
+ # end
127
+ #
128
+ # The block receives `(relation, value)` and MUST return a relation (narrowing
129
+ # only). `type` / `description` are metadata surfaced by `resource_schema`. The
130
+ # value arrives from a TOP-LEVEL request param keyed by `name` (see ListExecutor),
131
+ # applied BEFORE the allowlist `filterable` filters. api-agnostic: the gem stores
132
+ # and calls the block without inspecting it.
133
+ def filter(name, type:, description:, &applier)
134
+ @custom_filters[name.to_sym] = CustomFilter.new(name: name.to_sym, type:, description:, applier:)
135
+ end
136
+
137
+ # Request-facing custom-filter key (symbol) => CustomFilter. Consumed by the list
138
+ # executor (which applies each block whose key is present in the request params)
139
+ # and by resource_schema (which surfaces each filter's type/description).
140
+ attr_reader :custom_filters
141
+
51
142
  # The OAuth-style scope a token MUST carry to reach this resource via the
52
143
  # generic tools (e.g. "notifications__read"). Declared explicitly per resource:
53
144
  #
@@ -80,24 +171,64 @@ class McpToolkit::Resource
80
171
  #
81
172
  # Unmapped/unknown keys are rejected by the list executor, never silently
82
173
  # dropped, so a typo surfaces as actionable feedback.
83
- def filterable(mapping = nil)
84
- return @filterable if mapping.nil?
174
+ # Accepts a Hash (merged now) OR a callable returning a Hash (resolved LAZILY on
175
+ # first read). The lazy form lets a host derive the map from something that must
176
+ # NOT be touched at registration/boot time — e.g. a DB-backed column list
177
+ # (`Model.column_names`): registration typically runs inside an initializer's
178
+ # `to_prepare`, before the database may exist (e.g. CI's `db:create`), so hitting
179
+ # the DB there aborts boot. A callable source is invoked at most once — on the
180
+ # first read (a tool call, when the DB is present) — then memoized.
181
+ def filterable(mapping = nil, &block)
182
+ source = block || mapping
183
+ return filterable_columns if source.nil?
85
184
 
86
- mapping.each do |request_key, column|
87
- @filterable[request_key.to_sym] = column.to_sym
185
+ if source.respond_to?(:call)
186
+ @filterable_source = source
187
+ else
188
+ merge_filterable!(source)
88
189
  end
89
- @filterable
190
+ self
90
191
  end
91
192
 
92
193
  # Request-facing filter keys (symbols, sorted) this resource can be filtered
93
194
  # by. Surfaced via the `resource_schema` tool.
94
195
  def filterable_keys
95
- @filterable.keys.sort
196
+ filterable_columns.keys.sort
197
+ end
198
+
199
+ # Declares companion-key requirements for filter keys: a request-facing key
200
+ # that is only valid when another key is passed alongside it (the canonical
201
+ # case is a polymorphic foreign key, type-ambiguous without its `*_type`):
202
+ #
203
+ # filter_requirements created_by_id: :created_by_type
204
+ #
205
+ # The list executor rejects a filter using the key without its companion, and
206
+ # `resource_schema` surfaces the requirement (`relationships[].filter.requires`)
207
+ # so a client can discover it. The companion key MUST itself be declared
208
+ # `filterable` — otherwise the requirement is unsatisfiable (the executor
209
+ # rejects the companion as an unknown key) while the schema still advertises
210
+ # it. Like `filterable`, accepts a Hash (merged now) OR a callable returning
211
+ # one (resolved lazily on first successful read, then memoized) so a host can
212
+ # derive the map without touching the DB at boot. Read with no arg.
213
+ def filter_requirements(mapping = nil, &block)
214
+ source = block || mapping
215
+ if source.nil?
216
+ resolve_filter_requirements_source!
217
+ return @filter_requirements
218
+ end
219
+
220
+ if source.respond_to?(:call)
221
+ @filter_requirements_source = source
222
+ else
223
+ merge_filter_requirements!(source)
224
+ end
225
+ self
96
226
  end
97
227
 
98
228
  # Request-facing filter key (symbol) => backing column (symbol). Consumed by
99
229
  # the list executor to build the WHERE clause.
100
230
  def filterable_columns
231
+ resolve_filterable_source!
101
232
  @filterable
102
233
  end
103
234
 
@@ -126,4 +257,40 @@ class McpToolkit::Resource
126
257
 
127
258
  serializer.declared_associations
128
259
  end
260
+
261
+ private
262
+
263
+ # Resolves a lazily-provided filterable source (a callable) on the first
264
+ # SUCCESSFUL `filterable_columns` / `filterable_keys` read — then drops it, so
265
+ # later reads are pure Hash access. This is what keeps a DB-derived map (e.g.
266
+ # `Model.column_names`) out of registration/boot time. The source is cleared
267
+ # only AFTER it returns: a raising callable (a transient DB hiccup) stays
268
+ # registered and is retried on the next read, instead of permanently and
269
+ # silently resolving the allowlist to `{}`.
270
+ def resolve_filterable_source!
271
+ return unless @filterable_source
272
+
273
+ resolved = @filterable_source.call || {}
274
+ @filterable_source = nil
275
+ merge_filterable!(resolved)
276
+ end
277
+
278
+ def merge_filterable!(mapping)
279
+ mapping.each { |request_key, column| @filterable[request_key.to_sym] = column.to_sym }
280
+ end
281
+
282
+ # Same lazy-resolution contract as the filterable source: resolved on the
283
+ # first successful read, cleared only after the callable returns so a
284
+ # transient failure is retried rather than silently dropping the map.
285
+ def resolve_filter_requirements_source!
286
+ return unless @filter_requirements_source
287
+
288
+ resolved = @filter_requirements_source.call || {}
289
+ @filter_requirements_source = nil
290
+ merge_filter_requirements!(resolved)
291
+ end
292
+
293
+ def merge_filter_requirements!(mapping)
294
+ mapping.each { |key, required| @filter_requirements[key.to_sym] = required.to_sym }
295
+ end
129
296
  end
@@ -38,11 +38,15 @@ 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:,
44
+ resource_filters:,
43
45
  standard_filters: STANDARD_FILTERS,
46
+ sparse_fieldsets: true,
47
+ filter_examples:,
44
48
  filters:
45
- }
49
+ }.compact
46
50
  end
47
51
 
48
52
  private
@@ -50,7 +54,7 @@ class McpToolkit::ResourceSchema
50
54
  attr_reader :resource, :model, :registry
51
55
 
52
56
  def attributes
53
- resource.attribute_names.map { |name| attribute_schema(name) }
57
+ @attributes ||= resource.attribute_names.map { |name| attribute_schema(name) }
54
58
  end
55
59
 
56
60
  def attribute_schema(name)
@@ -59,8 +63,20 @@ class McpToolkit::ResourceSchema
59
63
  name:,
60
64
  type: type ? type.to_s : COMPUTED_TYPE,
61
65
  format: type ? TYPE_FORMATS[type] : nil,
62
- filterable: filterable_column_for(name).present?
63
- }.compact
66
+ filterable: filterable_column_for(name).present?,
67
+ operators: operators_for(name)
68
+ }
69
+ end
70
+
71
+ # The filter operators an attribute accepts, derived from the backing column's
72
+ # type via McpToolkit::Filtering.operators_for. `[]` for a non-filterable
73
+ # attribute (or one with no backing column) — self-describing so a client
74
+ # knows exactly which `{ op:, value: }` conditions `list` will accept.
75
+ def operators_for(attribute_name)
76
+ pair = filterable_column_for(attribute_name)
77
+ return [] unless pair
78
+
79
+ McpToolkit::Filtering.operators_for(column_type(pair.last))
64
80
  end
65
81
 
66
82
  # Per-attribute equality filters this resource accepts on the `list` tool's
@@ -79,6 +95,88 @@ class McpToolkit::ResourceSchema
79
95
  end
80
96
  end
81
97
 
98
+ # The resource's custom filters (Resource#filter) — resource-specific filters
99
+ # passed as TOP-LEVEL params of the `list` tool (NOT inside `filter`), each
100
+ # applied by a host-supplied block. Surfaced with name/type/description so a
101
+ # client can discover them; `[]` for a resource that declares none.
102
+ # Entries keep nil type/description keys (rather than compacting) — the
103
+ # pre-gem contract emitted them, and an always-present shape is easier for a
104
+ # client to consume.
105
+ def resource_filters
106
+ resource.custom_filters.each_value.map do |custom_filter|
107
+ {
108
+ name: custom_filter.name.to_s,
109
+ type: custom_filter.type&.to_s,
110
+ description: custom_filter.description
111
+ }
112
+ end
113
+ end
114
+
115
+ # Ready-to-use `filter` payload examples built from this resource's own
116
+ # filterable attributes and relationships, so a client can copy a working
117
+ # shape instead of deriving it from the operator lists.
118
+ def filter_examples
119
+ [equality_example, comparison_example, range_example, relationship_example].compact
120
+ end
121
+
122
+ def equality_example
123
+ attribute = example_attributes.find { |candidate| candidate[:type] == "string" } || example_attributes.first
124
+ return unless attribute
125
+
126
+ { attribute[:name] => sample_value(attribute[:type]) }
127
+ end
128
+
129
+ def comparison_example
130
+ attribute = comparison_attribute
131
+ return unless attribute
132
+
133
+ { attribute[:name] => { op: "gt", value: sample_value(attribute[:type]) } }
134
+ end
135
+
136
+ def range_example
137
+ attribute = comparison_attribute
138
+ return unless attribute
139
+
140
+ {
141
+ attribute[:name] => [
142
+ { op: "gteq", value: sample_value(attribute[:type]) },
143
+ { op: "lt", value: sample_value(attribute[:type]) }
144
+ ]
145
+ }
146
+ end
147
+
148
+ def relationship_example
149
+ relationship = relationships.find { |candidate| candidate[:filter] }
150
+ return unless relationship
151
+
152
+ example = { relationship[:filter][:keys].first => 1 }
153
+ example[relationship[:filter][:requires]] = "User" if relationship[:filter][:requires]
154
+ example
155
+ end
156
+
157
+ def filterable_attributes
158
+ attributes.select { |attribute| attribute[:filterable] }
159
+ end
160
+
161
+ # `id` is filterable but uninteresting as an example (use the `ids` filter for that).
162
+ def example_attributes
163
+ filterable_attributes.reject { |attribute| attribute[:name] == :id }
164
+ end
165
+
166
+ def comparison_attribute
167
+ example_attributes.find { |attribute| attribute[:operators].include?("gt") }
168
+ end
169
+
170
+ def sample_value(type)
171
+ case type.to_s
172
+ when "integer" then 1
173
+ when "decimal", "float" then "100.0"
174
+ when "boolean" then true
175
+ when "datetime", "date" then "2026-01-01T00:00:00Z"
176
+ else "..."
177
+ end
178
+ end
179
+
82
180
  # Backing column for a serialized attribute that is also a filter key, if any.
83
181
  # A filter key may be a public alias (e.g. booking_id -> synced_booking_id) so
84
182
  # we match on either the request key or the column.
@@ -89,22 +187,46 @@ class McpToolkit::ResourceSchema
89
187
  end
90
188
 
91
189
  def relationships
92
- resource.association_descriptors.map { |association| relationship_schema(association) }
190
+ @relationships ||= resource.association_descriptors.map { |association| relationship_schema(association) }
93
191
  end
94
192
 
95
- # One relationship entry. Beyond the link key/kind/polymorphic flag it now also
96
- # names the `target_resource`the registered resource this link resolves to,
97
- # callable via `list`/`get` so e.g. a `scheduled_notifications.notification`
98
- # link is discoverably the `notifications` resource rather than a name to guess.
99
- # It is omitted (additive/backward-compatible) when the target can't be resolved
100
- # (e.g. a polymorphic link).
193
+ # One relationship entry: the link key/kind/polymorphic flag, the registered
194
+ # resource the link resolves to emitted BOTH as `resource` (nullable) and,
195
+ # when resolved, as `target_resource` (so e.g. a
196
+ # `scheduled_notifications.notification` link is discoverably the
197
+ # `notifications` resource rather than a name to guess) and, when the
198
+ # link's foreign key is filterable, a `filter` block telling a client HOW to
199
+ # filter by the relationship (see #relationship_filter).
101
200
  def relationship_schema(association)
102
201
  target = target_resource_for(association)
103
- {
202
+ schema = {
104
203
  name: association.links_key,
105
204
  kind: association.type.to_s,
106
205
  polymorphic: association.polymorphic || false,
107
- target_resource: target&.name
206
+ resource: target&.name,
207
+ filter: relationship_filter(association.links_key)
208
+ }
209
+ schema[:target_resource] = target.name if target
210
+ schema
211
+ end
212
+
213
+ # How to filter by a relationship, when its foreign key is in the filter
214
+ # allowlist: the accepted request keys (the FK, plus the bare link name when
215
+ # aliased), the backing column's type, its operators, and — for a key that
216
+ # cannot be used alone (e.g. a polymorphic FK needing its `*_type`) — the
217
+ # companion key it `requires` (see Resource#filter_requirements).
218
+ def relationship_filter(name)
219
+ id_key = :"#{name}_id"
220
+ column = resource.filterable_columns[id_key]
221
+ return nil unless column
222
+
223
+ keys = [id_key]
224
+ keys << name.to_sym if resource.filterable_columns.key?(name.to_sym)
225
+ {
226
+ keys:,
227
+ type: column_type(column).to_s,
228
+ operators: operators_for(id_key),
229
+ requires: resource.filter_requirements[id_key]
108
230
  }.compact
109
231
  end
110
232
 
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The association shape the gem reads off a serializer's
4
+ # `declared_associations` (ResourceSchema's relationship entries,
5
+ # FieldSelection's valid `fields` names). A host adapting its OWN serializer
6
+ # framework builds these rather than re-deriving the duck-type by hand:
7
+ # `links_key` / `type` / `polymorphic` / `name`, plus an optional `serializer`
8
+ # responding to `model_class` (see TargetRef) so the target resource resolves.
9
+ McpToolkit::Serializer::AssociationDescriptor = Struct.new(
10
+ :name, :type, :polymorphic, :links_key, :serializer, keyword_init: true
11
+ )
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Minimal object satisfying the gem's `association.serializer.model_class`
4
+ # probe (ResourceSchema's target-resource resolution): carries the model an
5
+ # association resolves to. Pair with AssociationDescriptor when adapting a
6
+ # host serializer framework.
7
+ McpToolkit::Serializer::TargetRef = Struct.new(:model_class)