mcp_toolkit 0.4.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +231 -0
  3. data/config/routes.rb +1 -1
  4. data/lib/mcp_toolkit/authority/controller_methods.rb +15 -0
  5. data/lib/mcp_toolkit/authority/registry_tool_provider.rb +9 -10
  6. data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
  7. data/lib/mcp_toolkit/authority/tools/base.rb +31 -3
  8. data/lib/mcp_toolkit/authority/tools/get.rb +2 -1
  9. data/lib/mcp_toolkit/authority/tools/list.rb +49 -1
  10. data/lib/mcp_toolkit/authority/tools/resource_schema.rb +10 -2
  11. data/lib/mcp_toolkit/authority/tools/resources.rb +26 -4
  12. data/lib/mcp_toolkit/configuration.rb +161 -7
  13. data/lib/mcp_toolkit/dispatcher.rb +17 -3
  14. data/lib/mcp_toolkit/engine.rb +5 -2
  15. data/lib/mcp_toolkit/engine_controllers.rb +11 -3
  16. data/lib/mcp_toolkit/filtering.rb +168 -23
  17. data/lib/mcp_toolkit/gateway/aggregator.rb +25 -5
  18. data/lib/mcp_toolkit/list_executor.rb +48 -4
  19. data/lib/mcp_toolkit/resource.rb +55 -6
  20. data/lib/mcp_toolkit/resource_schema.rb +125 -16
  21. data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
  22. data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
  23. data/lib/mcp_toolkit/session.rb +2 -2
  24. data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
  25. data/lib/mcp_toolkit/tools/authority_base.rb +23 -2
  26. data/lib/mcp_toolkit/tools/base.rb +23 -3
  27. data/lib/mcp_toolkit/tools/get.rb +1 -1
  28. data/lib/mcp_toolkit/tools/list.rb +27 -9
  29. data/lib/mcp_toolkit/tools/resource_schema.rb +12 -3
  30. data/lib/mcp_toolkit/tools/resources.rb +30 -7
  31. data/lib/mcp_toolkit/usage_metering/recorder.rb +28 -2
  32. data/lib/mcp_toolkit/version.rb +1 -1
  33. metadata +5 -1
@@ -66,10 +66,20 @@ class McpToolkit::ListExecutor
66
66
  relation
67
67
  end
68
68
 
69
- # Order by `id` when the primary key is numeric; otherwise by `created_at`
70
- # (a non-numeric PK does not sort meaningfully).
69
+ # Order by `id` when the primary key is numeric; otherwise per
70
+ # `config.non_numeric_pk_order`: `created_at` with the primary key as a
71
+ # tiebreaker (default — rows bulk-inserted in one transaction share a
72
+ # `created_at`, and without a total order offset pagination could duplicate
73
+ # or skip rows), or the primary key alone for a host preserving a pre-gem
74
+ # order-by-id contract. `numeric_primary_key?` returning false guarantees the
75
+ # model exposes a non-nil primary key, so it can be read directly here.
71
76
  def apply_order(relation)
72
- relation.order(numeric_primary_key? ? :id : :created_at)
77
+ return relation.order(:id) if numeric_primary_key?
78
+
79
+ pk = resource.model.primary_key.to_sym
80
+ return relation.order(pk) if McpToolkit.config.non_numeric_pk_order == :primary_key
81
+
82
+ relation.order(:created_at, pk)
73
83
  end
74
84
 
75
85
  def numeric_primary_key?
@@ -98,9 +108,10 @@ class McpToolkit::ListExecutor
98
108
 
99
109
  mapping = resource.filterable_columns
100
110
  validate_filter_keys!(filter, mapping)
111
+ validate_filter_companions!(filter)
101
112
 
102
113
  filter.each do |request_key, value|
103
- next if value.nil? || value == ""
114
+ next if skipped_filter_value?(value)
104
115
 
105
116
  column = mapping[request_key.to_sym]
106
117
  relation = McpToolkit::Filtering.apply(relation, column, value)
@@ -108,6 +119,35 @@ class McpToolkit::ListExecutor
108
119
  relation
109
120
  end
110
121
 
122
+ # Under :tokenized semantics an empty string means "no filter" (a JSON null
123
+ # still flows through as an IS NULL filter, like the "null" token — see
124
+ # McpToolkit::Filtering); under :literal every value reaches the WHERE clause
125
+ # verbatim.
126
+ def skipped_filter_value?(value)
127
+ value == "" && McpToolkit.config.bare_filter_value_semantics != :literal
128
+ end
129
+
130
+ # A filter key may declare a companion key it cannot be used without (e.g. a
131
+ # polymorphic foreign key is type-ambiguous without its `*_type`) — see
132
+ # Resource#filter_requirements. Rejected up front rather than producing a
133
+ # subtly wrong WHERE. A key whose value the apply loop will SKIP counts as
134
+ # not provided — a skipped ("" under :tokenized) companion must not satisfy
135
+ # the requirement, or the FK would be applied alone: exactly the
136
+ # type-ambiguous WHERE the requirement exists to prevent.
137
+ def validate_filter_companions!(filter)
138
+ requirements = resource.filter_requirements
139
+ return if requirements.empty?
140
+
141
+ provided = filter.reject { |_key, value| skipped_filter_value?(value) }.keys.map(&:to_sym)
142
+ requirements.each do |key, required|
143
+ next unless provided.include?(key)
144
+ next if provided.include?(required)
145
+
146
+ raise McpToolkit::Errors::InvalidParams,
147
+ "filter attribute #{key} requires #{required} to also be provided"
148
+ end
149
+ end
150
+
111
151
  def validate_filter_keys!(filter, mapping)
112
152
  keys = filter.keys.map(&:to_sym)
113
153
  unknown = keys - mapping.keys
@@ -123,6 +163,10 @@ class McpToolkit::ListExecutor
123
163
  return relation if params[:ids].blank?
124
164
 
125
165
  ids = params[:ids].to_s.split(",").map(&:strip).compact_blank
166
+ # Bound the IN-set the same way the per-attribute filters are bounded — the
167
+ # top-level `ids` param builds `WHERE id IN (...)` on its own path, so it
168
+ # must honor config.max_filter_values too (else it's an unbounded IN clause).
169
+ McpToolkit::Filtering.enforce_filter_limit!(ids.length, McpToolkit.config)
126
170
  ids.empty? ? relation : relation.where(id: ids)
127
171
  end
128
172
 
@@ -42,6 +42,8 @@ class McpToolkit::Resource
42
42
  @superusers_only = false
43
43
  @filterable = {}
44
44
  @filterable_source = nil
45
+ @filter_requirements = {}
46
+ @filter_requirements_source = nil
45
47
  @custom_filters = {}
46
48
  @required_permissions_scope = nil
47
49
  @extras = {}
@@ -194,6 +196,35 @@ class McpToolkit::Resource
194
196
  filterable_columns.keys.sort
195
197
  end
196
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
226
+ end
227
+
197
228
  # Request-facing filter key (symbol) => backing column (symbol). Consumed by
198
229
  # the list executor to build the WHERE clause.
199
230
  def filterable_columns
@@ -229,19 +260,37 @@ class McpToolkit::Resource
229
260
 
230
261
  private
231
262
 
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.
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 `{}`.
236
270
  def resolve_filterable_source!
237
271
  return unless @filterable_source
238
272
 
239
- source = @filterable_source
273
+ resolved = @filterable_source.call || {}
240
274
  @filterable_source = nil
241
- merge_filterable!(source.call || {})
275
+ merge_filterable!(resolved)
242
276
  end
243
277
 
244
278
  def merge_filterable!(mapping)
245
279
  mapping.each { |request_key, column| @filterable[request_key.to_sym] = column.to_sym }
246
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
247
296
  end
@@ -41,9 +41,12 @@ class McpToolkit::ResourceSchema
41
41
  note: resource.note,
42
42
  attributes:,
43
43
  relationships:,
44
+ resource_filters:,
44
45
  standard_filters: STANDARD_FILTERS,
46
+ sparse_fieldsets: true,
47
+ filter_examples:,
45
48
  filters:
46
- }
49
+ }.compact
47
50
  end
48
51
 
49
52
  private
@@ -51,7 +54,7 @@ class McpToolkit::ResourceSchema
51
54
  attr_reader :resource, :model, :registry
52
55
 
53
56
  def attributes
54
- resource.attribute_names.map { |name| attribute_schema(name) }
57
+ @attributes ||= resource.attribute_names.map { |name| attribute_schema(name) }
55
58
  end
56
59
 
57
60
  def attribute_schema(name)
@@ -62,18 +65,18 @@ class McpToolkit::ResourceSchema
62
65
  format: type ? TYPE_FORMATS[type] : nil,
63
66
  filterable: filterable_column_for(name).present?,
64
67
  operators: operators_for(name)
65
- }.compact
68
+ }
66
69
  end
67
70
 
68
71
  # 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
+ # 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.
72
75
  def operators_for(attribute_name)
73
76
  pair = filterable_column_for(attribute_name)
74
77
  return [] unless pair
75
78
 
76
- McpToolkit::Filtering::OPERATORS_BY_TYPE.fetch(column_type(pair.last), [])
79
+ McpToolkit::Filtering.operators_for(column_type(pair.last))
77
80
  end
78
81
 
79
82
  # Per-attribute equality filters this resource accepts on the `list` tool's
@@ -92,6 +95,88 @@ class McpToolkit::ResourceSchema
92
95
  end
93
96
  end
94
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
+
95
180
  # Backing column for a serialized attribute that is also a filter key, if any.
96
181
  # A filter key may be a public alias (e.g. booking_id -> synced_booking_id) so
97
182
  # we match on either the request key or the column.
@@ -102,22 +187,46 @@ class McpToolkit::ResourceSchema
102
187
  end
103
188
 
104
189
  def relationships
105
- resource.association_descriptors.map { |association| relationship_schema(association) }
190
+ @relationships ||= resource.association_descriptors.map { |association| relationship_schema(association) }
106
191
  end
107
192
 
108
- # One relationship entry. Beyond the link key/kind/polymorphic flag it now also
109
- # names the `target_resource`the registered resource this link resolves to,
110
- # callable via `list`/`get` so e.g. a `scheduled_notifications.notification`
111
- # link is discoverably the `notifications` resource rather than a name to guess.
112
- # It is omitted (additive/backward-compatible) when the target can't be resolved
113
- # (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).
114
200
  def relationship_schema(association)
115
201
  target = target_resource_for(association)
116
- {
202
+ schema = {
117
203
  name: association.links_key,
118
204
  kind: association.type.to_s,
119
205
  polymorphic: association.polymorphic || false,
120
- 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]
121
230
  }.compact
122
231
  end
123
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
+ )
@@ -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)
@@ -28,9 +28,9 @@ class McpToolkit::Session
28
28
  stored = config.cache_store.read(cache_key(id))
29
29
  return nil unless stored
30
30
 
31
- # Sliding expiry: bump TTL on every successful lookup.
31
+ # Sliding expiry: bump TTL on every successful lookup, re-writing the row
32
+ # untouched.
32
33
  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
34
  new(id:, data: stored[:data] || {})
35
35
  end
36
36
 
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rewrites backticked generic-tool references ("use the `resources` tool") inside
4
+ # tool prose so they always name the tools exactly as they appear in the serving
5
+ # server's `tools/list`. Two serving paths need this: the authority's own generic
6
+ # tools when the host configures a `generic_tool_name_prefix` (a client would
7
+ # otherwise be pointed at an unprefixed tool that does not exist), and the
8
+ # gateway's aggregated upstream tools, whose names are re-keyed into the
9
+ # `<app>__<tool>` namespace while their prose would otherwise keep naming the
10
+ # upstream's bare tools.
11
+ #
12
+ # Only EXACT backticked base names are rewritten; other backticked terms
13
+ # (`resource`, `filter`, `target_resource`, ...) never match.
14
+ module McpToolkit::ToolReferenceRewriter
15
+ GENERIC_TOOL_REFERENCES = /`(resource_schema|resources|list|get)`/
16
+
17
+ module_function
18
+
19
+ # Rewrites `node` (a String, or a Hash/Array structure such as a tool
20
+ # definition or input schema, walked recursively) with `name_prefix` applied to
21
+ # every backticked generic-tool reference. Non-string leaves pass through
22
+ # untouched; an empty prefix returns the node verbatim.
23
+ def rewrite(node, name_prefix)
24
+ return node if name_prefix.to_s.empty?
25
+
26
+ case node
27
+ when String then node.gsub(GENERIC_TOOL_REFERENCES) { "`#{name_prefix}#{Regexp.last_match(1)}`" }
28
+ when Hash then node.transform_values { |value| rewrite(value, name_prefix) }
29
+ when Array then node.map { |value| rewrite(value, name_prefix) }
30
+ else node
31
+ end
32
+ end
33
+ end
@@ -105,6 +105,15 @@ class McpToolkit::Tools::AuthorityBase
105
105
  raise McpToolkit::Protocol::InvalidRequest, "#{resource.name} is restricted to superuser (user-scoped) MCP tokens"
106
106
  end
107
107
 
108
+ # ArgumentErrors Ruby itself raises while binding the request arguments to the
109
+ # subclass's `#call` signature: a missing/unknown keyword or wrong arity. Their
110
+ # messages carry only keyword names (the tool's own declared params) and counts,
111
+ # so surfacing them tells a client how to fix the call. Every OTHER ArgumentError
112
+ # is raised from inside the tool's business logic (e.g. `Integer("x")`) and its
113
+ # message may carry SQL, internal class names, or a value — so it is sanitized
114
+ # like any unexpected error rather than relayed verbatim.
115
+ ARGUMENT_BINDING_ERROR_MESSAGE = /\A(missing keyword|unknown keyword|wrong number of arguments)/
116
+
108
117
  # Runs the tool's business logic (the subclass's `#call`) with error mapping.
109
118
  # Arrives with symbol-keyed arguments from the dispatcher.
110
119
  def execute(**arguments)
@@ -114,9 +123,11 @@ class McpToolkit::Tools::AuthorityBase
114
123
  # (e.g. InvalidParams); let it bubble untouched so the client sees it.
115
124
  raise
116
125
  rescue ArgumentError => e
117
- raise McpToolkit::Protocol::InvalidParams, e.message
126
+ raise McpToolkit::Protocol::InvalidParams, e.message if ARGUMENT_BINDING_ERROR_MESSAGE.match?(e.message)
127
+
128
+ raise sanitized_internal_error(e)
118
129
  rescue StandardError => e
119
- raise McpToolkit::Protocol::InternalError, e.message
130
+ raise sanitized_internal_error(e)
120
131
  end
121
132
 
122
133
  # The subclass implements its business logic here, receiving the tool arguments
@@ -124,4 +135,14 @@ class McpToolkit::Tools::AuthorityBase
124
135
  def call(**_arguments)
125
136
  raise NotImplementedError, "#{self.class} must implement #call"
126
137
  end
138
+
139
+ private
140
+
141
+ # An UNEXPECTED error's message may carry SQL, internal class names, or a
142
+ # hostname — it must not reach the caller (the dispatcher relays a
143
+ # Protocol::Error's message verbatim). Log the detail; return a generic error.
144
+ def sanitized_internal_error(error)
145
+ McpToolkit.config.logger&.error("MCP tool #{self.class} error: #{error.message}\n#{error.backtrace&.join("\n")}")
146
+ McpToolkit::Protocol::InternalError.new("Internal error")
147
+ end
127
148
  end
@@ -26,7 +26,7 @@ class McpToolkit::Tools::Base < MCP::Tool
26
26
  # `required_scope` is the explicitly-declared scope a token must carry (the
27
27
  # caller resolves it from the resource — see Registry#required_scope_for).
28
28
  # Empty/nil => no scope check (authorized_for_scope? treats "" as a pass).
29
- def self.with_account(server_context, account_id: nil, required_scope: nil)
29
+ def self.with_account(server_context, account_id: nil, required_scope: nil, resource: nil)
30
30
  config = config_from(server_context)
31
31
  context = McpToolkit::Auth::Authenticator.call(
32
32
  token: server_context[:bearer_token],
@@ -40,6 +40,9 @@ class McpToolkit::Tools::Base < MCP::Tool
40
40
  return error_response("Unauthorized: token lacks the #{required_scope.inspect} scope")
41
41
  end
42
42
 
43
+ superuser_refusal = superuser_only_refusal(resource, context.introspection)
44
+ return superuser_refusal if superuser_refusal
45
+
43
46
  text_response(yield(context.scope_root))
44
47
  rescue McpToolkit::Errors::Unauthorized => e
45
48
  error_response("Unauthorized: #{e.message}")
@@ -52,7 +55,7 @@ class McpToolkit::Tools::Base < MCP::Tool
52
55
  # which reveal shape, not tenant data, so a superuser shouldn't have to pin an
53
56
  # account just to discover what exists. Empty/nil `required_scope` => no scope
54
57
  # check.
55
- def self.with_authentication(server_context, required_scope: nil)
58
+ def self.with_authentication(server_context, required_scope: nil, resource: nil)
56
59
  config = config_from(server_context)
57
60
  introspection = McpToolkit::Auth::Introspection.call(server_context[:bearer_token], config:)
58
61
  return error_response("Unauthorized: invalid or expired token") unless introspection.valid?
@@ -61,11 +64,28 @@ class McpToolkit::Tools::Base < MCP::Tool
61
64
  return error_response("Unauthorized: token lacks the #{required_scope.inspect} scope")
62
65
  end
63
66
 
64
- text_response(yield)
67
+ superuser_refusal = superuser_only_refusal(resource, introspection)
68
+ return superuser_refusal if superuser_refusal
69
+
70
+ # Yields the introspection so a discovery tool (`resources`) can HIDE
71
+ # superuser-only resources from a non-superuser caller (get/list/resource_schema
72
+ # instead pass `resource:` above to REFUSE a specific one).
73
+ text_response(yield(introspection))
65
74
  rescue McpToolkit::Errors::InvalidParams => e
66
75
  error_response("Invalid request: #{e.message}")
67
76
  end
68
77
 
78
+ # Refuses a superuser-only resource for a non-superuser caller (get / list /
79
+ # resource_schema); nil = allowed. Mirrors the authority path's
80
+ # `ensure_resource_accessible!`. `resources` HIDES such resources instead of
81
+ # refusing — it filters on `introspection.superuser?` directly.
82
+ def self.superuser_only_refusal(resource, introspection)
83
+ return nil unless resource&.superusers_only?
84
+ return nil if introspection.superuser?
85
+
86
+ error_response("Unauthorized: #{resource.name} is restricted to superuser (user-scoped) MCP tokens")
87
+ end
88
+
69
89
  def self.text_response(payload)
70
90
  text = payload.is_a?(String) ? payload : JSON.generate(payload)
71
91
  MCP::Tool::Response.new([{ type: "text", text: }])
@@ -48,7 +48,7 @@ class McpToolkit::Tools::Get < McpToolkit::Tools::Base
48
48
  # the scope check (and so an unknown resource is a clean tool error).
49
49
  descriptor = resolve_descriptor(resource, config)
50
50
  required_scope = config.registry.required_scope_for(descriptor)
51
- with_account(server_context, account_id:, required_scope:) do |scope_root|
51
+ with_account(server_context, account_id:, required_scope:, resource: descriptor) do |scope_root|
52
52
  McpToolkit::GetExecutor.call(resource: descriptor, scope_root:, id:, fields:)
53
53
  end
54
54
  rescue McpToolkit::Errors::InvalidParams => e
@@ -15,11 +15,26 @@ class McpToolkit::Tools::List < McpToolkit::Tools::Base
15
15
  - limit: page size (default 25, max 100)
16
16
  - offset: pagination offset (default 0)
17
17
 
18
- Per-attribute equality filters:
19
- - filter: an object of { <key>: <value> } exact-match filters, applied ON TOP of the
20
- account scope (they can only narrow, never widen). Each resource advertises its
21
- available filter keys via `resource_schema` (the `filters` array). Unknown keys are
22
- rejected.
18
+ Per-attribute filters:
19
+ - filter: an object of { <key>: <value> } filters, applied ON TOP of the account scope
20
+ (they can only narrow, never widen). Each resource advertises its available filter keys
21
+ and operators via `resource_schema`. Unknown keys are rejected.
22
+ - A bare value matches by equality. A comma-separated string or an array of scalars
23
+ matches ANY of the values (IN), e.g. { "status": "booked,canceled" } or
24
+ { "status": ["booked", "canceled"] }. The string "null" (or a JSON null) matches
25
+ records where the value is NULL.
26
+ - An operator condition is an object { "op": <operator>, "value": <value> }, e.g.
27
+ { "price": { "op": "gteq", "value": 100 } }. An array of conditions ANDs them into a
28
+ range: { "price": [{ "op": "gteq", "value": 100 }, { "op": "lt", "value": 200 }] }.
29
+ Each attribute's supported operators are listed in `resource_schema`.
30
+ - Some filter keys require a companion key (e.g. a polymorphic id and its type) —
31
+ `resource_schema` advertises these under a relationship's `filter.requires`; pass
32
+ both keys together.
33
+
34
+ Resource-specific filters:
35
+ - Some resources accept additional filters advertised in `resource_schema` under
36
+ `resource_filters`. Pass each as a TOP-LEVEL argument (NOT inside `filter`), e.g.
37
+ { "resource": "...", "<name>": <value> }.
23
38
 
24
39
  Sparse fieldset:
25
40
  - fields: names of the attributes and/or relationships to include in each record, as an
@@ -50,8 +65,8 @@ class McpToolkit::Tools::List < McpToolkit::Tools::Base
50
65
  },
51
66
  filter: {
52
67
  type: "object",
53
- description: "Per-attribute exact-match equality filters, e.g. { \"booking_id\": 42 }. " \
54
- "See a resource's `resource_schema` `filters` for the keys it accepts.",
68
+ description: "Per-attribute filters, e.g. { \"booking_id\": 42 }. See a resource's " \
69
+ "`resource_schema` `filters` for the keys and operators it accepts.",
55
70
  additionalProperties: true
56
71
  },
57
72
  limit: { type: "integer", description: "Page size (default 25, max 100)" },
@@ -65,7 +80,10 @@ class McpToolkit::Tools::List < McpToolkit::Tools::Base
65
80
  "resource's `resource_schema` for valid attribute and relationship names."
66
81
  }
67
82
  },
68
- required: ["resource"]
83
+ required: ["resource"],
84
+ # Resource-specific filters (resource_schema's `resource_filters`) arrive as
85
+ # top-level arguments, so the schema must not advertise a closed shape.
86
+ additionalProperties: true
69
87
  )
70
88
 
71
89
  def self.call(server_context:, resource: nil, account_id: nil, **params)
@@ -74,7 +92,7 @@ class McpToolkit::Tools::List < McpToolkit::Tools::Base
74
92
  # the scope check (and so an unknown resource is a clean tool error).
75
93
  descriptor = resolve_descriptor(resource, config)
76
94
  required_scope = config.registry.required_scope_for(descriptor)
77
- with_account(server_context, account_id:, required_scope:) do |scope_root|
95
+ with_account(server_context, account_id:, required_scope:, resource: descriptor) do |scope_root|
78
96
  McpToolkit::ListExecutor.call(resource: descriptor, scope_root:, params:)
79
97
  end
80
98
  rescue McpToolkit::Errors::InvalidParams => e
@@ -7,11 +7,19 @@ class McpToolkit::Tools::ResourceSchema < McpToolkit::Tools::Base
7
7
  description <<~DESC.strip
8
8
  Describe a single read-only resource in detail. Pass the resource name as `resource` (use
9
9
  the `resources` tool to discover names). Returns:
10
- - attributes: every field in the response, each with its `type` and a value `format` hint
10
+ - attributes: every field in the response, each with its `type`, a value `format` hint,
11
+ whether it is `filterable`, and the filter `operators` it accepts
11
12
  - relationships: associated resources emitted in the record's `links`; each names the
12
13
  `target_resource` it resolves to (callable via `list`/`get`)
13
14
  - standard_filters: ids, updated_since, limit, offset (accepted by the `list` tool)
14
- - filters: the per-attribute equality filter keys the `list` tool accepts
15
+ - filters: the per-attribute equality/operator filter keys the `list` tool accepts in
16
+ its `filter` argument
17
+ - resource_filters: resource-specific filters, if any — each is passed as a TOP-LEVEL
18
+ argument of the `list` tool (NOT inside `filter`), e.g. { "resource": "...",
19
+ "<name>": <value> }
20
+ - filter_examples: ready-to-use `filter` payloads for this resource
21
+ A relationship's `filter` block lists the keys that filter by it; when it names a
22
+ `requires` key (e.g. a polymorphic id needing its type), pass BOTH keys together.
15
23
  The `attributes` and `relationships` names are also the valid values for the `fields` sparse
16
24
  fieldset argument on `get` / `list`. Call this before `list` to learn a resource's shape.
17
25
  DESC
@@ -31,7 +39,8 @@ class McpToolkit::Tools::ResourceSchema < McpToolkit::Tools::Base
31
39
  # Resolve the resource FIRST so its effective required scope gates discovery
32
40
  # of THIS resource's shape (and an unknown resource is a clean tool error).
33
41
  descriptor = resolve_descriptor(resource, config)
34
- with_authentication(server_context, required_scope: config.registry.required_scope_for(descriptor)) do
42
+ with_authentication(server_context, required_scope: config.registry.required_scope_for(descriptor),
43
+ resource: descriptor) do
35
44
  McpToolkit::ResourceSchema.call(descriptor, registry: config.registry)
36
45
  end
37
46
  rescue McpToolkit::Errors::InvalidParams => e