Class: McpToolkit::Resource

Inherits:
Object
  • Object
show all
Defined in:
lib/mcp_toolkit/resource.rb

Overview

Descriptor for a single read-only resource exposed via the MCP server. Built via McpToolkit.registry.register; consumed by the List/Get executors and the resource_schema tool.

The scope_block is the account-rooting relation: it receives the resolved local scope root (typically an Account) and MUST return a relation already scoped so that every row belongs to that root (directly via a foreign key, or transitively through an owning record). This is the single tenancy chokepoint — every get/list query roots on it.

The serializer is INJECTABLE per resource: it may be a subclass of the gem's McpToolkit::Serializer::Base, or any class satisfying the serializer contract (serialize_one / serialize_collection) — e.g. an app's existing serializer.

Defined Under Namespace

Classes: CustomFilter, NotConfigured

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name) ⇒ Resource

Returns a new instance of Resource.



35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/mcp_toolkit/resource.rb', line 35

def initialize(name)
  @name = name.to_s
  @model = nil
  @serializer = nil
  @scope_block = nil
  @description = nil
  @note = nil
  @superusers_only = false
  @filterable = {}
  @filterable_source = nil
  @filter_requirements = {}
  @filter_requirements_source = nil
  @custom_filters = {}
  @required_permissions_scope = nil
  @extras = {}
end

Instance Attribute Details

#custom_filtersObject (readonly)

Request-facing custom-filter key (symbol) => CustomFilter. Consumed by the list executor (which applies each block whose key is present in the request params) and by resource_schema (which surfaces each filter's type/description).



140
141
142
# File 'lib/mcp_toolkit/resource.rb', line 140

def custom_filters
  @custom_filters
end

#extrasObject (readonly)

The full host-extras bag (symbol/whatever key => value). Read-only view for a resource_finalizer that wants to iterate every declared extra.



67
68
69
# File 'lib/mcp_toolkit/resource.rb', line 67

def extras
  @extras
end

#nameObject (readonly)

Returns the value of attribute name.



33
34
35
# File 'lib/mcp_toolkit/resource.rb', line 33

def name
  @name
end

Instance Method Details

#association_descriptorsObject

Association descriptors (the links shape) read off the serializer.



255
256
257
258
259
# File 'lib/mcp_toolkit/resource.rb', line 255

def association_descriptors
  return [] unless serializer.respond_to?(:declared_associations)

  serializer.declared_associations
end

#attribute_namesObject

Serialized attribute names (the response shape), read off the serializer's declared attributes. Requires a serializer that exposes declared_attributes (the gem's base does); resource_schema degrades gracefully otherwise.



248
249
250
251
252
# File 'lib/mcp_toolkit/resource.rb', line 248

def attribute_names
  return [] unless serializer.respond_to?(:declared_attributes)

  serializer.declared_attributes.map(&:to_sym)
end

#description(text = nil) ⇒ Object



84
85
86
87
# File 'lib/mcp_toolkit/resource.rb', line 84

def description(text = nil)
  @description = text if text
  @description
end

#effective_required_permissions_scope(default = nil) ⇒ Object

The scope actually enforced for this resource: its own declared scope if set, otherwise the registry-level default passed in. nil = no scope required.



156
157
158
# File 'lib/mcp_toolkit/resource.rb', line 156

def effective_required_permissions_scope(default = nil)
  @required_permissions_scope || default
end

#extra(key, value = UNSET) ⇒ Object

A generic, api-agnostic metadata bag for host-defined "extras" — declarations the gem does not model itself (e.g. an app's ORM dependency list used to build a serializer). It is the storage behind config.registry.resource_extension (the host DSL that writes extras inside a registration block) and config.registry.resource_finalizer (which reads them back to derive gem-native fields such as serializer / filterable). Write with extra(:key, value); read with extra(:key) (nil when unset). The gem never inspects the values.



59
60
61
62
63
# File 'lib/mcp_toolkit/resource.rb', line 59

def extra(key, value = UNSET)
  return @extras[key] if value.equal?(UNSET)

  @extras[key] = value
end

#filter(name, type:, description:, &applier) ⇒ Object

Declares a resource-specific ("custom") filter: a request-facing name whose value is applied to the already-scoped relation by the given block. Unlike the filterable allowlist (generic equality/operator filters on a declared column), a custom filter runs ARBITRARY host logic, so a host can express a relational filter the gem could not derive:

filter :rental_id, type: :integer, description: "Only rows for this rental" do |relation, value|
relation.joins(:booking).where(bookings: { rental_id: value })
end

The block receives (relation, value) and MUST return a relation (narrowing only). type / description are metadata surfaced by resource_schema. The value arrives from a TOP-LEVEL request param keyed by name (see ListExecutor), applied BEFORE the allowlist filterable filters. api-agnostic: the gem stores and calls the block without inspecting it.



133
134
135
# File 'lib/mcp_toolkit/resource.rb', line 133

def filter(name, type:, description:, &applier)
  @custom_filters[name.to_sym] = CustomFilter.new(name: name.to_sym, type:, description:, applier:)
end

#filter_requirements(mapping = nil, &block) ⇒ Object

Declares companion-key requirements for filter keys: a request-facing key that is only valid when another key is passed alongside it (the canonical case is a polymorphic foreign key, type-ambiguous without its *_type):

filter_requirements created_by_id: :created_by_type

The list executor rejects a filter using the key without its companion, and resource_schema surfaces the requirement (relationships[].filter.requires) so a client can discover it. The companion key MUST itself be declared filterable — otherwise the requirement is unsatisfiable (the executor rejects the companion as an unknown key) while the schema still advertises it. Like filterable, accepts a Hash (merged now) OR a callable returning one (resolved lazily on first successful read, then memoized) so a host can derive the map without touching the DB at boot. Read with no arg.



213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/mcp_toolkit/resource.rb', line 213

def filter_requirements(mapping = nil, &block)
  source = block || mapping
  if source.nil?
    resolve_filter_requirements_source!
    return @filter_requirements
  end

  if source.respond_to?(:call)
    @filter_requirements_source = source
  else
    merge_filter_requirements!(source)
  end
  self
end

#filterable(mapping = nil, &block) ⇒ Object

Declares the per-attribute filters this resource accepts on the list tool. Each entry maps a REQUEST-FACING filter key to the backing DATABASE COLUMN the WHERE is applied to. The mapping is what lets the consumer-facing key differ from the storage column (e.g. exposing a synced foreign key under its public name):

filterable booking_id: :synced_booking_id

A declared key accepts both a bare equality value AND operator-based conditions ({ op:, value: } or an array of them, ANDed) — see McpToolkit::Filtering for the supported operators per column type.

Unmapped/unknown keys are rejected by the list executor, never silently dropped, so a typo surfaces as actionable feedback. Accepts a Hash (merged now) OR a callable returning a Hash (resolved LAZILY on first read). The lazy form lets a host derive the map from something that must NOT be touched at registration/boot time — e.g. a DB-backed column list (Model.column_names): registration typically runs inside an initializer's to_prepare, before the database may exist (e.g. CI's db:create), so hitting the DB there aborts boot. A callable source is invoked at most once — on the first read (a tool call, when the DB is present) — then memoized.



181
182
183
184
185
186
187
188
189
190
191
# File 'lib/mcp_toolkit/resource.rb', line 181

def filterable(mapping = nil, &block)
  source = block || mapping
  return filterable_columns if source.nil?

  if source.respond_to?(:call)
    @filterable_source = source
  else
    merge_filterable!(source)
  end
  self
end

#filterable_columnsObject

Request-facing filter key (symbol) => backing column (symbol). Consumed by the list executor to build the WHERE clause.



230
231
232
233
# File 'lib/mcp_toolkit/resource.rb', line 230

def filterable_columns
  resolve_filterable_source!
  @filterable
end

#filterable_keysObject

Request-facing filter keys (symbols, sorted) this resource can be filtered by. Surfaced via the resource_schema tool.



195
196
197
# File 'lib/mcp_toolkit/resource.rb', line 195

def filterable_keys
  filterable_columns.keys.sort
end

#model(klass = nil) ⇒ Object



69
70
71
72
# File 'lib/mcp_toolkit/resource.rb', line 69

def model(klass = nil)
  @model = klass if klass
  @model
end

#note(text = nil) ⇒ Object

Free-form usage caveat surfaced by the resources / resource_schema tools, e.g. to flag a resource as internal-debugging-only and not to be interpreted without domain knowledge. Read with no arg. api-agnostic passthrough string.



92
93
94
95
# File 'lib/mcp_toolkit/resource.rb', line 92

def note(text = nil)
  @note = text if text
  @note
end

#required_permissions_scope(scope = nil) ⇒ Object

The OAuth-style scope a token MUST carry to reach this resource via the generic tools (e.g. "notifications__read"). Declared explicitly per resource:

required_permissions_scope "notifications__read"

Default nil = no scope required for this resource (unless the registry sets a default — see Registry#default_required_permissions_scope). Read with no arg.



149
150
151
152
# File 'lib/mcp_toolkit/resource.rb', line 149

def required_permissions_scope(scope = nil)
  @required_permissions_scope = scope if scope
  @required_permissions_scope
end

#resolve_relation(scope_root) ⇒ Object

The account-scoped relation for this resource. Raises if misconfigured so a registry mistake fails loudly rather than leaking an unscoped query.

Raises:



237
238
239
240
241
242
243
# File 'lib/mcp_toolkit/resource.rb', line 237

def resolve_relation(scope_root)
  raise NotConfigured, "resource #{name.inspect} has no scope block" unless scope
  raise NotConfigured, "resource #{name.inspect} has no model" unless model
  raise NotConfigured, "resource #{name.inspect} has no serializer" unless serializer

  scope.call(scope_root)
end

#scope(&block) ⇒ Object



79
80
81
82
# File 'lib/mcp_toolkit/resource.rb', line 79

def scope(&block)
  @scope_block = block if block
  @scope_block
end

#serializer(klass = nil) ⇒ Object



74
75
76
77
# File 'lib/mcp_toolkit/resource.rb', line 74

def serializer(klass = nil)
  @serializer = klass if klass
  @serializer
end

#superusers_only!Object

Restricts this resource to superuser (cross-tenant) callers on the AUTHORITY path: an authority tool refuses get / list / resource_schema for a non-superuser and HIDES the resource from resources discovery. Declared in a resource's registration block:

McpToolkit.registry.register(:audit_events) do
superusers_only!
...
end

Generic and api-agnostic — the gem never names an app concept; the caller's superuser-ness is derived by the Authority::Context off the principal.



109
110
111
# File 'lib/mcp_toolkit/resource.rb', line 109

def superusers_only!
  @superusers_only = true
end

#superusers_only?Boolean

Whether this resource is restricted to superuser callers (default false).

Returns:

  • (Boolean)


114
115
116
# File 'lib/mcp_toolkit/resource.rb', line 114

def superusers_only?
  @superusers_only
end