mcp_toolkit 0.3.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 (43) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +43 -0
  3. data/.rubocop.yml +98 -0
  4. data/CHANGELOG.md +123 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +301 -0
  7. data/Rakefile +21 -0
  8. data/app/controllers/mcp_toolkit/server_controller.rb +19 -0
  9. data/config/routes.rb +18 -0
  10. data/lib/mcp_toolkit/auth/authenticator.rb +99 -0
  11. data/lib/mcp_toolkit/auth/authority.rb +75 -0
  12. data/lib/mcp_toolkit/auth/authority_server_client.rb +50 -0
  13. data/lib/mcp_toolkit/auth/introspection.rb +162 -0
  14. data/lib/mcp_toolkit/configuration.rb +209 -0
  15. data/lib/mcp_toolkit/engine.rb +21 -0
  16. data/lib/mcp_toolkit/errors/base.rb +10 -0
  17. data/lib/mcp_toolkit/errors/configuration_error.rb +5 -0
  18. data/lib/mcp_toolkit/errors/invalid_params.rb +5 -0
  19. data/lib/mcp_toolkit/errors/unauthorized.rb +5 -0
  20. data/lib/mcp_toolkit/field_selection.rb +93 -0
  21. data/lib/mcp_toolkit/filtering.rb +152 -0
  22. data/lib/mcp_toolkit/get_executor.rb +32 -0
  23. data/lib/mcp_toolkit/list_executor.rb +137 -0
  24. data/lib/mcp_toolkit/registry.rb +67 -0
  25. data/lib/mcp_toolkit/resource.rb +129 -0
  26. data/lib/mcp_toolkit/resource_schema.rb +163 -0
  27. data/lib/mcp_toolkit/serialization.rb +62 -0
  28. data/lib/mcp_toolkit/serializer/base.rb +285 -0
  29. data/lib/mcp_toolkit/server.rb +44 -0
  30. data/lib/mcp_toolkit/session.rb +46 -0
  31. data/lib/mcp_toolkit/sql_sanitizer.rb +14 -0
  32. data/lib/mcp_toolkit/token_kinds.rb +14 -0
  33. data/lib/mcp_toolkit/tools/base.rb +100 -0
  34. data/lib/mcp_toolkit/tools/get.rb +57 -0
  35. data/lib/mcp_toolkit/tools/list.rb +83 -0
  36. data/lib/mcp_toolkit/tools/resource_schema.rb +40 -0
  37. data/lib/mcp_toolkit/tools/resources.rb +27 -0
  38. data/lib/mcp_toolkit/transport/controller_methods.rb +226 -0
  39. data/lib/mcp_toolkit/unknown_resource_message.rb +75 -0
  40. data/lib/mcp_toolkit/version.rb +5 -0
  41. data/lib/mcp_toolkit.rb +118 -0
  42. data/sig/mcp_toolkit.rbs +4 -0
  43. metadata +147 -0
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A parsed, validated sparse-fieldset request (JSON:API's `fields[type]`) for a
4
+ # single resource. Requested names share ONE flat namespace covering both declared
5
+ # ATTRIBUTES and relationship link keys — exactly as JSON:API conflates them.
6
+ #
7
+ # Built by the List/Get executors from the tool's `fields` argument. A nil
8
+ # selection (blank/absent `fields`) means "all fields": the executors skip it
9
+ # entirely and serialize as before, so the default path is completely untouched.
10
+ #
11
+ # A present selection is applied one of two ways, decided by McpToolkit::Serialization:
12
+ # * NATIVELY — `names` is passed to a serializer whose `serialize_one` /
13
+ # `serialize_collection` declares a `fields:` keyword (the gem's Base does),
14
+ # so unselected attributes and relationships are never computed at all.
15
+ # * By PRUNING the fully-serialized hash (`prune_record` / `prune_collection`)
16
+ # for an injected serializer that predates the `fields:` kwarg — a pure
17
+ # shape-level filter, so ANY contract-satisfying serializer stays sparse-able.
18
+ class McpToolkit::FieldSelection
19
+ LINKS_KEY = "links"
20
+
21
+ # Parse the raw tool argument (a comma-separated string OR an array of names)
22
+ # into a selection, or nil when nothing was requested. Validates the requested
23
+ # names against the resource's known members when the resource can describe them
24
+ # (the Base serializer exposes `declared_attributes`); an unknown name is a
25
+ # clean InvalidParams so a typo is actionable rather than silently dropped.
26
+ def self.build(resource:, raw:)
27
+ names = parse(raw)
28
+ return nil if names.empty?
29
+
30
+ new(resource:, names:).tap(&:validate!)
31
+ end
32
+
33
+ # Normalizes a CSV string or an array into a de-duplicated list of symbols.
34
+ def self.parse(raw)
35
+ list = raw.is_a?(Array) ? raw : raw.to_s.split(",")
36
+ list.map { |name| name.to_s.strip }.reject(&:empty?).map(&:to_sym).uniq
37
+ end
38
+
39
+ # The validated requested field names (symbols), passed to a fields-aware
40
+ # serializer as its `fields:` argument.
41
+ attr_reader :names
42
+
43
+ def initialize(resource:, names:)
44
+ @resource = resource
45
+ @names = names
46
+ end
47
+
48
+ # Raises InvalidParams if any requested name is neither a declared attribute nor
49
+ # a relationship link key. Skipped for serializers that can't describe their
50
+ # members (resource_schema degrades the same way) — those are pruned leniently.
51
+ def validate!
52
+ return unless @resource.serializer.respond_to?(:declared_attributes)
53
+
54
+ unknown = @names - known_members
55
+ return if unknown.empty?
56
+
57
+ selectable = known_members.map(&:to_s).sort.join(", ").presence || "(none)"
58
+ raise McpToolkit::Errors::InvalidParams,
59
+ "unknown field(s): #{unknown.join(", ")}. Selectable fields for this resource: #{selectable}"
60
+ end
61
+
62
+ # Prune a single serialized record hash down to the requested members.
63
+ # Shape-driven (needs no serializer metadata): every key is an attribute except
64
+ # the string `"links"` block, which is itself narrowed to the requested link
65
+ # keys and dropped entirely when nothing under it was requested.
66
+ def prune_record(hash)
67
+ requested = @names.map(&:to_s)
68
+ hash.each_with_object({}) do |(key, value), pruned|
69
+ if key.to_s == LINKS_KEY
70
+ links = (value || {}).select { |link_key, _| requested.include?(link_key.to_s) }
71
+ pruned[LINKS_KEY] = links unless links.empty?
72
+ elsif requested.include?(key.to_s)
73
+ pruned[key] = value
74
+ end
75
+ end
76
+ end
77
+
78
+ # Prune the collection wrapper: each array value holds the rows (prune each);
79
+ # the `meta` hash and any other non-array entry pass through untouched.
80
+ def prune_collection(wrapper)
81
+ wrapper.transform_values do |value|
82
+ value.is_a?(Array) ? value.map { |row| prune_record(row) } : value
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def known_members
89
+ @known_members ||=
90
+ @resource.attribute_names.map(&:to_sym) +
91
+ @resource.association_descriptors.map { |assoc| assoc.links_key.to_sym }
92
+ end
93
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Applies the `list` tool's `filter` params to an already-scoped relation, with
4
+ # the following semantics:
5
+ #
6
+ # * a BARE value filters by equality: filter: { booking_id: 42 }
7
+ # (a comma-separated string becomes an IN lookup: filter: { status: "a,b" })
8
+ # * an { op:, value: } HASH filters with an operator: filter: { price: { op: "gteq", value: 100 } }
9
+ # * an ARRAY of those hashes ANDs them (ranges):
10
+ # filter: { price: [{ op: "gteq", value: 100 }, { op: "lt", value: 200 }] }
11
+ #
12
+ # Supported operators (validated against the column's DB type):
13
+ # eq, not_eq, gt, gteq, lt, lteq — numeric / datetime columns
14
+ # eq, not_eq, in, matches, does_not_match — string columns (matches => case-insensitive LIKE)
15
+ # eq, not_eq — boolean columns
16
+ #
17
+ # Allowlist-safe: only the resource's declared `filterable` keys may be filtered,
18
+ # each resolved to its backing column. Unknown keys are rejected upstream by the
19
+ # ListExecutor, and an operator unsupported for a column's type raises
20
+ # InvalidParams — there is no arbitrary column or SQL injection surface.
21
+ module McpToolkit::Filtering
22
+ OPERATORS_BY_TYPE = {
23
+ integer: %w[eq not_eq gt gteq lt lteq].freeze,
24
+ float: %w[eq not_eq gt gteq lt lteq].freeze,
25
+ decimal: %w[eq not_eq gt gteq lt lteq].freeze,
26
+ datetime: %w[eq not_eq gt gteq lt lteq].freeze,
27
+ date: %w[eq not_eq gt gteq lt lteq].freeze,
28
+ string: %w[eq not_eq in matches does_not_match].freeze,
29
+ text: %w[eq not_eq in matches does_not_match].freeze,
30
+ boolean: %w[eq not_eq].freeze
31
+ }.freeze
32
+
33
+ # Operators that map straight onto an Arel predication method.
34
+ AREL_PREDICATIONS = %w[eq not_eq gt gteq lt lteq in matches does_not_match].freeze
35
+
36
+ NULL_TOKEN = "null"
37
+
38
+ # @param relation the already account-scoped relation
39
+ # @param column [Symbol] the backing DB column (already resolved from the allowlist)
40
+ # @param value the filter value: a bare value, an { op:, value: } hash, or an
41
+ # array of such hashes
42
+ # @param config [McpToolkit::Configuration] supplies the SQL sanitizer used to
43
+ # escape LIKE wildcards
44
+ # @return the relation with the filter(s) applied
45
+ def self.apply(relation, column, value, config: McpToolkit.config)
46
+ if compound?(value)
47
+ apply_condition(relation, column, value, config:)
48
+ elsif collection?(value)
49
+ value.inject(relation) { |rel, condition| apply_condition(rel, column, condition, config:) }
50
+ else
51
+ # Bare value: equality. A comma-separated string becomes an IN lookup,
52
+ # matching the implicit `eq` semantics.
53
+ relation.where(column => equality_value(value))
54
+ end
55
+ end
56
+
57
+ # A single operator-based condition, e.g. { op: "gt", value: 1000 }.
58
+ def self.compound?(value)
59
+ condition_hash?(value) && (value.key?(:op) || value.key?("op"))
60
+ end
61
+
62
+ # Several operator-based conditions on one attribute, ANDed (ranges).
63
+ def self.collection?(value)
64
+ value.is_a?(Array) && value.any? && value.all? { |element| compound?(element) }
65
+ end
66
+
67
+ def self.condition_hash?(value)
68
+ value.is_a?(Hash)
69
+ end
70
+
71
+ def self.apply_condition(relation, column, condition, config:)
72
+ operator = fetch(condition, :op).to_s
73
+ raise McpToolkit::Errors::InvalidParams, "a filter operator is required" if operator.empty?
74
+
75
+ type = column_type(relation, column)
76
+ validate_operator!(operator, type, column)
77
+
78
+ raw = fetch(condition, :value)
79
+ relation.where(predicate_for(relation, column, operator, raw, config:))
80
+ end
81
+
82
+ # Reads a key regardless of symbol/string keys; checks presence (not
83
+ # truthiness) so a literal `false` / `nil` value survives.
84
+ def self.fetch(condition, key)
85
+ return condition[key] if condition.key?(key)
86
+
87
+ condition[key.to_s] if condition.key?(key.to_s)
88
+ end
89
+
90
+ def self.column_type(relation, column)
91
+ model = relation.respond_to?(:model) ? relation.model : nil
92
+ return nil unless model.respond_to?(:columns_hash)
93
+
94
+ model.columns_hash[column.to_s]&.type
95
+ end
96
+
97
+ def self.validate_operator!(operator, type, column)
98
+ allowed = OPERATORS_BY_TYPE[type]
99
+ if allowed.nil?
100
+ raise McpToolkit::Errors::InvalidParams,
101
+ "'#{column}' cannot be filtered with operators"
102
+ end
103
+ return if allowed.include?(operator)
104
+
105
+ raise McpToolkit::Errors::InvalidParams,
106
+ "'#{operator}' operator is not supported for #{column} (#{type}). " \
107
+ "Supported operators: #{allowed.join(", ")}."
108
+ end
109
+
110
+ # Builds the predicate to hand to `relation.where`. For a real ActiveRecord
111
+ # relation we build an Arel node (so it composes with the scope safely and is
112
+ # immune to SQL injection); a relation without an `arel_table` (the in-memory
113
+ # test fake) receives a portable Predicate value object it knows how to apply.
114
+ def self.predicate_for(relation, column, operator, raw, config:)
115
+ value = normalize_value(operator, raw, config:)
116
+ arel_operator = operator == "eq" ? "in" : operator
117
+
118
+ model = relation.respond_to?(:model) ? relation.model : nil
119
+ if model.respond_to?(:arel_table)
120
+ model.arel_table[column.to_sym].public_send(arel_operator, value)
121
+ else
122
+ Predicate.new(column.to_sym, arel_operator, value)
123
+ end
124
+ end
125
+
126
+ # `eq` against a (possibly comma-separated) value becomes an IN set. `matches`
127
+ # / `does_not_match` wrap the value in `%...%` with LIKE wildcards escaped so
128
+ # they match literally. `null` => nil for every operator.
129
+ def self.normalize_value(operator, raw, config:)
130
+ return nil if raw.to_s == NULL_TOKEN
131
+
132
+ case operator
133
+ when "eq", "in"
134
+ raw.to_s.split(",")
135
+ when "matches", "does_not_match"
136
+ "%#{config.sql_sanitizer.sanitize_sql_like(raw.to_s)}%"
137
+ else
138
+ raw
139
+ end
140
+ end
141
+
142
+ def self.equality_value(value)
143
+ return nil if value.to_s == NULL_TOKEN
144
+
145
+ str = value.to_s
146
+ str.include?(",") ? str.split(",") : value
147
+ end
148
+
149
+ # Portable representation of an operator predicate, applied by the in-memory
150
+ # test relation. Production uses Arel nodes instead (see .predicate_for).
151
+ Predicate = Struct.new(:column, :operator, :value)
152
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Runs a read-only "show" query for a registered resource by id, rooted on the
4
+ # scoped relation so cross-scope ids are simply not found. Serializes via the
5
+ # resource's serializer.
6
+ class McpToolkit::GetExecutor
7
+ def self.call(resource:, scope_root:, id:, fields: nil)
8
+ new(resource:, scope_root:, id:, fields:).call
9
+ end
10
+
11
+ def initialize(resource:, scope_root:, id:, fields: nil)
12
+ @resource = resource
13
+ @scope_root = scope_root
14
+ @id = id
15
+ @fields = fields
16
+ end
17
+
18
+ def call
19
+ raise McpToolkit::Errors::InvalidParams, "id is required" if id.blank?
20
+
21
+ # Validate the sparse fieldset BEFORE the lookup so a bad `fields` fails fast.
22
+ selection = McpToolkit::FieldSelection.build(resource:, raw: fields)
23
+ record = resource.resolve_relation(scope_root).find_by(id:)
24
+ raise McpToolkit::Errors::InvalidParams, "#{resource.name} not found for id=#{id}" unless record
25
+
26
+ McpToolkit::Serialization.new(resource.serializer, selection).one(record, scope: scope_root)
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :resource, :scope_root, :id, :fields
32
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Runs a read-only, paginated "list" query for a registered resource, rooted on
4
+ # the scoped relation. Supports the standard `ids`, `updated_since`, `limit`,
5
+ # `offset` filters plus the resource's declared per-attribute filters (equality
6
+ # AND operator-based — see McpToolkit::Filtering), and serializes via the
7
+ # resource's serializer, producing the `{ <root> => [...], meta: {...} }` wrapper.
8
+ # An optional `fields` param applies a sparse fieldset to each row (see
9
+ # McpToolkit::FieldSelection).
10
+ class McpToolkit::ListExecutor
11
+ DEFAULT_LIMIT = 25
12
+ MAX_LIMIT = 100
13
+ # Column types whose primary key sorts naturally by `id`. Anything else (e.g. a
14
+ # string/uuid PK) sorts by `created_at` instead.
15
+ NUMERIC_PK_TYPES = %i[integer bigint].freeze
16
+
17
+ def self.call(resource:, scope_root:, params:)
18
+ new(resource:, scope_root:, params:).call
19
+ end
20
+
21
+ def initialize(resource:, scope_root:, params:)
22
+ @resource = resource
23
+ @scope_root = scope_root
24
+ @params = (params || {}).deep_symbolize_keys
25
+ end
26
+
27
+ def call
28
+ # Validate the sparse fieldset BEFORE running the query so a bad `fields`
29
+ # fails fast rather than after a needless count + fetch.
30
+ selection = McpToolkit::FieldSelection.build(resource:, raw: params[:fields])
31
+ relation = build_relation
32
+ total_count = relation.count
33
+ rows = paginate(relation).to_a
34
+
35
+ McpToolkit::Serialization.new(resource.serializer, selection).collection(
36
+ rows, scope: scope_root, total_count:, limit:, offset:
37
+ )
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :resource, :scope_root, :params
43
+
44
+ def build_relation
45
+ relation = resource.resolve_relation(scope_root)
46
+ relation = apply_ids(relation)
47
+ relation = apply_updated_since(relation)
48
+ relation = apply_attribute_filters(relation)
49
+ apply_order(relation)
50
+ end
51
+
52
+ # Order by `id` when the primary key is numeric; otherwise by `created_at`
53
+ # (a non-numeric PK does not sort meaningfully).
54
+ def apply_order(relation)
55
+ relation.order(numeric_primary_key? ? :id : :created_at)
56
+ end
57
+
58
+ def numeric_primary_key?
59
+ model = resource.model
60
+ return true unless model.respond_to?(:columns_hash) && model.respond_to?(:primary_key)
61
+
62
+ pk = model.primary_key
63
+ return true if pk.nil?
64
+
65
+ type = model.columns_hash[pk.to_s]&.type
66
+ type.nil? || NUMERIC_PK_TYPES.include?(type)
67
+ end
68
+
69
+ # Applies per-attribute filters (`filter: { key: value | { op:, value: } | [...] }`).
70
+ # Each request-facing key is resolved against the resource's declared allowlist
71
+ # (`Resource#filterable`) to its backing column, then added as WHERE clause(s) on
72
+ # TOP of the already scoped relation — filtering composes with scoping and can
73
+ # only ever NARROW it, never widen it. Equality and operator-based semantics are
74
+ # delegated to McpToolkit::Filtering.
75
+ #
76
+ # Unknown filter keys are rejected with InvalidParams, so the caller gets
77
+ # actionable feedback instead of a silently-ignored filter.
78
+ def apply_attribute_filters(relation)
79
+ filter = params[:filter]
80
+ return relation if filter.blank?
81
+
82
+ mapping = resource.filterable_columns
83
+ validate_filter_keys!(filter, mapping)
84
+
85
+ filter.each do |request_key, value|
86
+ next if value.nil? || value == ""
87
+
88
+ column = mapping[request_key.to_sym]
89
+ relation = McpToolkit::Filtering.apply(relation, column, value)
90
+ end
91
+ relation
92
+ end
93
+
94
+ def validate_filter_keys!(filter, mapping)
95
+ keys = filter.keys.map(&:to_sym)
96
+ unknown = keys - mapping.keys
97
+ return if unknown.empty?
98
+
99
+ allowed = mapping.keys.sort.join(", ")
100
+ raise McpToolkit::Errors::InvalidParams,
101
+ "unknown filter attribute(s): #{unknown.join(", ")}. " \
102
+ "Filterable attributes for this resource: #{allowed.presence || "(none)"}"
103
+ end
104
+
105
+ def apply_ids(relation)
106
+ return relation if params[:ids].blank?
107
+
108
+ ids = params[:ids].to_s.split(",").map(&:strip).compact_blank
109
+ ids.empty? ? relation : relation.where(id: ids)
110
+ end
111
+
112
+ def apply_updated_since(relation)
113
+ return relation if params[:updated_since].blank?
114
+
115
+ time = parse_time(params[:updated_since])
116
+ relation.where("#{relation.table_name}.updated_at > ?", time)
117
+ end
118
+
119
+ def parse_time(value)
120
+ Time.zone.parse(value.to_s) || raise(ArgumentError)
121
+ rescue ArgumentError, TypeError
122
+ raise McpToolkit::Errors::InvalidParams, "updated_since must be an ISO 8601 timestamp"
123
+ end
124
+
125
+ def paginate(relation)
126
+ relation.offset(offset).limit(limit)
127
+ end
128
+
129
+ def limit
130
+ raw = params[:limit] || DEFAULT_LIMIT
131
+ [[raw.to_i, 1].max, MAX_LIMIT].min # rubocop:disable Style/ComparableClamp
132
+ end
133
+
134
+ def offset
135
+ [params[:offset].to_i, 0].max
136
+ end
137
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Central registry of read-only resources exposed via the MCP server. Resources
4
+ # are registered at boot (in a `to_prepare` initializer) and consumed by the
5
+ # generic `resources` / `resource_schema` / `get` / `list` tools.
6
+ #
7
+ # Instances are addressable so tests (and, in principle, multiple mounted
8
+ # servers) don't collide; the app-facing convenience is `McpToolkit.registry`,
9
+ # which returns the process-wide instance.
10
+ class McpToolkit::Registry
11
+ class UnknownResource < StandardError; end
12
+
13
+ def initialize
14
+ @resources = {}
15
+ @default_required_permissions_scope = nil
16
+ end
17
+
18
+ # Registry-wide DEFAULT required scope, so a satellite declares its scope ONCE
19
+ # for every resource instead of repeating it per resource:
20
+ #
21
+ # McpToolkit.registry.default_required_permissions_scope "notifications__read"
22
+ #
23
+ # A resource's own `required_permissions_scope` overrides this. Default nil = no
24
+ # scope required unless a resource declares its own. Read with no arg.
25
+ #
26
+ # Declared in the satellite's `configure` block (NOT inside `to_prepare`), so it
27
+ # survives `reset!` and stays set across dev reloads.
28
+ def default_required_permissions_scope(scope = nil)
29
+ @default_required_permissions_scope = scope if scope
30
+ @default_required_permissions_scope
31
+ end
32
+
33
+ def register(name, &)
34
+ resource = McpToolkit::Resource.new(name)
35
+ resource.instance_eval(&)
36
+ @resources[name.to_s] = resource
37
+ end
38
+
39
+ # The scope a token must carry to reach `resource` via the generic tools: the
40
+ # resource's own declared scope, else the registry default, else nil (no check).
41
+ def required_scope_for(resource)
42
+ resource.effective_required_permissions_scope(@default_required_permissions_scope)
43
+ end
44
+
45
+ def fetch(name)
46
+ find(name) or raise(UnknownResource, McpToolkit::UnknownResourceMessage.new(name, resource_names).build)
47
+ end
48
+
49
+ def find(name)
50
+ @resources[name.to_s]
51
+ end
52
+
53
+ def resources
54
+ @resources.values
55
+ end
56
+
57
+ def resource_names
58
+ @resources.keys
59
+ end
60
+
61
+ # 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.
64
+ def reset!
65
+ @resources = {}
66
+ end
67
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Descriptor for a single read-only resource exposed via the MCP server. Built
4
+ # via `McpToolkit.registry.register`; consumed by the List/Get executors and the
5
+ # resource_schema tool.
6
+ #
7
+ # The `scope_block` is the account-rooting relation: it receives the resolved
8
+ # local scope root (typically an `Account`) and MUST return a relation already
9
+ # scoped so that every row belongs to that root (directly via a foreign key, or
10
+ # transitively through an owning record). This is the single tenancy chokepoint —
11
+ # every `get`/`list` query roots on it.
12
+ #
13
+ # The `serializer` is INJECTABLE per resource: it may be a subclass of the gem's
14
+ # `McpToolkit::Serializer::Base`, or any class satisfying the serializer contract
15
+ # (`serialize_one` / `serialize_collection`) — e.g. an app's existing serializer.
16
+ class McpToolkit::Resource
17
+ class NotConfigured < StandardError; end
18
+
19
+ attr_reader :name
20
+
21
+ def initialize(name)
22
+ @name = name.to_s
23
+ @model = nil
24
+ @serializer = nil
25
+ @scope_block = nil
26
+ @description = nil
27
+ @filterable = {}
28
+ @required_permissions_scope = nil
29
+ end
30
+
31
+ def model(klass = nil)
32
+ @model = klass if klass
33
+ @model
34
+ end
35
+
36
+ def serializer(klass = nil)
37
+ @serializer = klass if klass
38
+ @serializer
39
+ end
40
+
41
+ def scope(&block)
42
+ @scope_block = block if block
43
+ @scope_block
44
+ end
45
+
46
+ def description(text = nil)
47
+ @description = text if text
48
+ @description
49
+ end
50
+
51
+ # The OAuth-style scope a token MUST carry to reach this resource via the
52
+ # generic tools (e.g. "notifications__read"). Declared explicitly per resource:
53
+ #
54
+ # required_permissions_scope "notifications__read"
55
+ #
56
+ # Default nil = no scope required for this resource (unless the registry sets a
57
+ # default — see Registry#default_required_permissions_scope). Read with no arg.
58
+ def required_permissions_scope(scope = nil)
59
+ @required_permissions_scope = scope if scope
60
+ @required_permissions_scope
61
+ end
62
+
63
+ # The scope actually enforced for this resource: its own declared scope if set,
64
+ # otherwise the registry-level `default` passed in. nil = no scope required.
65
+ def effective_required_permissions_scope(default = nil)
66
+ @required_permissions_scope || default
67
+ end
68
+
69
+ # Declares the per-attribute filters this resource accepts on the `list` tool.
70
+ # Each entry maps a REQUEST-FACING filter key to the backing DATABASE COLUMN the
71
+ # WHERE is applied to. The mapping is what lets the consumer-facing key differ
72
+ # from the storage column (e.g. exposing a synced foreign key under its public
73
+ # name):
74
+ #
75
+ # filterable booking_id: :synced_booking_id
76
+ #
77
+ # A declared key accepts both a bare equality value AND operator-based
78
+ # conditions (`{ op:, value: }` or an array of them, ANDed) — see
79
+ # McpToolkit::Filtering for the supported operators per column type.
80
+ #
81
+ # Unmapped/unknown keys are rejected by the list executor, never silently
82
+ # dropped, so a typo surfaces as actionable feedback.
83
+ def filterable(mapping = nil)
84
+ return @filterable if mapping.nil?
85
+
86
+ mapping.each do |request_key, column|
87
+ @filterable[request_key.to_sym] = column.to_sym
88
+ end
89
+ @filterable
90
+ end
91
+
92
+ # Request-facing filter keys (symbols, sorted) this resource can be filtered
93
+ # by. Surfaced via the `resource_schema` tool.
94
+ def filterable_keys
95
+ @filterable.keys.sort
96
+ end
97
+
98
+ # Request-facing filter key (symbol) => backing column (symbol). Consumed by
99
+ # the list executor to build the WHERE clause.
100
+ def filterable_columns
101
+ @filterable
102
+ end
103
+
104
+ # The account-scoped relation for this resource. Raises if misconfigured so a
105
+ # registry mistake fails loudly rather than leaking an unscoped query.
106
+ def resolve_relation(scope_root)
107
+ raise NotConfigured, "resource #{name.inspect} has no scope block" unless scope
108
+ raise NotConfigured, "resource #{name.inspect} has no model" unless model
109
+ raise NotConfigured, "resource #{name.inspect} has no serializer" unless serializer
110
+
111
+ scope.call(scope_root)
112
+ end
113
+
114
+ # Serialized attribute names (the response shape), read off the serializer's
115
+ # declared attributes. Requires a serializer that exposes `declared_attributes`
116
+ # (the gem's base does); resource_schema degrades gracefully otherwise.
117
+ def attribute_names
118
+ return [] unless serializer.respond_to?(:declared_attributes)
119
+
120
+ serializer.declared_attributes.map(&:to_sym)
121
+ end
122
+
123
+ # Association descriptors (the `links` shape) read off the serializer.
124
+ def association_descriptors
125
+ return [] unless serializer.respond_to?(:declared_associations)
126
+
127
+ serializer.declared_associations
128
+ end
129
+ end