Class: McpToolkit::Resource
- Inherits:
-
Object
- Object
- McpToolkit::Resource
- 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
-
#custom_filters ⇒ Object
readonly
Request-facing custom-filter key (symbol) => CustomFilter.
-
#extras ⇒ Object
readonly
The full host-extras bag (symbol/whatever key => value).
-
#name ⇒ Object
readonly
Returns the value of attribute name.
Instance Method Summary collapse
-
#association_descriptors ⇒ Object
Association descriptors (the
linksshape) read off the serializer. -
#attribute_names ⇒ Object
Serialized attribute names (the response shape), read off the serializer's declared attributes.
- #description(text = nil) ⇒ Object
-
#effective_required_permissions_scope(default = nil) ⇒ Object
The scope actually enforced for this resource: its own declared scope if set, otherwise the registry-level
defaultpassed in. -
#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).
-
#filter(name, type:, description:, &applier) ⇒ Object
Declares a resource-specific ("custom") filter: a request-facing
namewhose value is applied to the already-scoped relation by the given block. -
#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):. -
#filterable(mapping = nil, &block) ⇒ Object
Declares the per-attribute filters this resource accepts on the
listtool. -
#filterable_columns ⇒ Object
Request-facing filter key (symbol) => backing column (symbol).
-
#filterable_keys ⇒ Object
Request-facing filter keys (symbols, sorted) this resource can be filtered by.
-
#initialize(name) ⇒ Resource
constructor
A new instance of Resource.
- #model(klass = nil) ⇒ Object
-
#note(text = nil) ⇒ Object
Free-form usage caveat surfaced by the
resources/resource_schematools, e.g. -
#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").
-
#resolve_relation(scope_root) ⇒ Object
The account-scoped relation for this resource.
- #scope(&block) ⇒ Object
- #serializer(klass = nil) ⇒ Object
-
#superusers_only! ⇒ Object
Restricts this resource to superuser (cross-tenant) callers on the AUTHORITY path: an authority tool refuses
get/list/resource_schemafor a non-superuser and HIDES the resource fromresourcesdiscovery. -
#superusers_only? ⇒ Boolean
Whether this resource is restricted to superuser callers (default false).
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_filters ⇒ Object (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 |
#extras ⇒ Object (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 |
#name ⇒ Object (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_descriptors ⇒ Object
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_names ⇒ Object
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 (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_columns ⇒ Object
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_keys ⇒ Object
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:
"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 (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.
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).
114 115 116 |
# File 'lib/mcp_toolkit/resource.rb', line 114 def superusers_only? @superusers_only end |