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
# 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
  @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).



138
139
140
# File 'lib/mcp_toolkit/resource.rb', line 138

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.



65
66
67
# File 'lib/mcp_toolkit/resource.rb', line 65

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.



224
225
226
227
228
# File 'lib/mcp_toolkit/resource.rb', line 224

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.



217
218
219
220
221
# File 'lib/mcp_toolkit/resource.rb', line 217

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

  serializer.declared_attributes.map(&:to_sym)
end

#description(text = nil) ⇒ Object



82
83
84
85
# File 'lib/mcp_toolkit/resource.rb', line 82

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.



154
155
156
# File 'lib/mcp_toolkit/resource.rb', line 154

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.



57
58
59
60
61
# File 'lib/mcp_toolkit/resource.rb', line 57

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.



131
132
133
# File 'lib/mcp_toolkit/resource.rb', line 131

def filter(name, type:, description:, &applier)
  @custom_filters[name.to_sym] = CustomFilter.new(name: name.to_sym, type:, description:, applier:)
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.



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

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.



199
200
201
202
# File 'lib/mcp_toolkit/resource.rb', line 199

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.



193
194
195
# File 'lib/mcp_toolkit/resource.rb', line 193

def filterable_keys
  filterable_columns.keys.sort
end

#model(klass = nil) ⇒ Object



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

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.



90
91
92
93
# File 'lib/mcp_toolkit/resource.rb', line 90

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.



147
148
149
150
# File 'lib/mcp_toolkit/resource.rb', line 147

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:



206
207
208
209
210
211
212
# File 'lib/mcp_toolkit/resource.rb', line 206

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



77
78
79
80
# File 'lib/mcp_toolkit/resource.rb', line 77

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

#serializer(klass = nil) ⇒ Object



72
73
74
75
# File 'lib/mcp_toolkit/resource.rb', line 72

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.



107
108
109
# File 'lib/mcp_toolkit/resource.rb', line 107

def superusers_only!
  @superusers_only = true
end

#superusers_only?Boolean

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

Returns:

  • (Boolean)


112
113
114
# File 'lib/mcp_toolkit/resource.rb', line 112

def superusers_only?
  @superusers_only
end