active_scaffold_mcp 0.1.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.
@@ -0,0 +1,339 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveScaffold
4
+ module MCP
5
+ class ListToolGenerator < ToolGenerator
6
+ OPERATOR_ALIASES = {
7
+ eq: :equal,
8
+ equality: :equal,
9
+ range: :between,
10
+ not_eq: :not_equal,
11
+ neq: :not_equal,
12
+ not_range: :not_between
13
+ }.freeze
14
+
15
+ class << self
16
+ def generate_tool_for(controller_class:, action_name:, action_config:, mcp_config:, settings:)
17
+ core_config = mcp_config.core
18
+ mappings = {}
19
+
20
+ list_tool = Class.new(ActiveScaffold::MCP::ListTool).tap do |tool_class|
21
+ configure_tool_class(tool_class, controller_class:, action_name:, core_config:, mcp_config:, settings:)
22
+ include_config_list(tool_class, core_config)
23
+ tool_class.read_only
24
+
25
+ views_tool_name = "#{tool_class.tool_name}_views" if saved_views_enabled?(core_config)
26
+ list_views = add_list_view_property(tool_class, core_config, views_tool_name)
27
+ add_column_filter_properties(tool_class, core_config, settings, mappings)
28
+ add_named_filter_properties(tool_class, action_config, settings, mappings)
29
+ add_search_property(tool_class, core_config, settings, mappings)
30
+ sortable_columns = add_sorting_properties(tool_class, core_config, action_config)
31
+ add_pagination_properties(tool_class, action_config)
32
+ generated_description = description(tool_class.title, mappings, sortable_columns, action_config, list_views)
33
+ tool_class.description(settings[:description] || generated_description)
34
+ tool_class.mcp_param_mappings = mappings.freeze
35
+
36
+ include_extensions(tool_class, settings)
37
+ end
38
+
39
+ return list_tool unless saved_views_enabled?(core_config)
40
+
41
+ [
42
+ list_tool,
43
+ build_list_views_tool(
44
+ controller_class:, action_name:, core_config:, mcp_config:, settings:, list_tool:
45
+ )
46
+ ]
47
+ end
48
+
49
+ private
50
+
51
+ def include_config_list(tool_class, core_config)
52
+ return unless core_config.actions.include?(:config_list)
53
+
54
+ tool_class.include "ActiveScaffold::Actions::ConfigList".constantize
55
+ end
56
+
57
+ def description(title, mappings, sortable_columns, action_config, list_views)
58
+ filter_types = []
59
+ filter_types << "column filters" if mappings.any? { |_name, mapping| mapping[:source].nil? }
60
+ filter_types << "named list filters" if mappings.any? { |_name, mapping| mapping[:source] == :named_filter }
61
+ filter_types << "full-text search" if mappings.any? { |_name, mapping| mapping[:source] == :search }
62
+
63
+ sentences = ["#{title} as a paginated collection"]
64
+ sentences << "Supports #{filter_types.to_sentence}" if filter_types.any?
65
+ search_mapping = mappings.values.find { |mapping| mapping[:source] == :search }
66
+ sentences << search_mapping[:description] if search_mapping
67
+ if sortable_columns.any?
68
+ sentences << "Supports sorting by #{formatted_column_names(sortable_columns)}"
69
+ sentences << default_sorting_description(action_config)
70
+ end
71
+ sentences << list_views[:description] if list_views
72
+ sentences << "Use limit and page for pagination"
73
+ "#{sentences.join('. ')}."
74
+ end
75
+
76
+ def add_list_view_property(tool_class, core_config, views_tool_name)
77
+ return unless core_config.actions.include?(:config_list)
78
+
79
+ config_list = core_config.config_list
80
+ configured_names = config_list.named_views.map { |view| view.name.to_s }
81
+ saved_views = saved_views_enabled?(core_config)
82
+ return if configured_names.empty? && !saved_views
83
+
84
+ sentences = ["Select a named list view to change the returned columns and default sorting"]
85
+ sentences << "Configured views: #{formatted_column_names(configured_names)}" if configured_names.any?
86
+ if saved_views
87
+ sentences << "Call `#{views_tool_name}` to get all views available to the current user"
88
+ end
89
+ description = sentences.join(". ")
90
+ property_options = {type: "string", description: description}
91
+ property_options[:enum] = configured_names unless saved_views
92
+ tool_class.property :list_view, **property_options
93
+ {description: description}
94
+ end
95
+
96
+ def saved_views_enabled?(core_config)
97
+ return false unless core_config.actions.include?(:config_list)
98
+
99
+ config_list = core_config.config_list
100
+ config_list.named_views_method.present? && config_list.save_to_user.present?
101
+ end
102
+
103
+ def build_list_views_tool(controller_class:, action_name:, core_config:, mcp_config:, settings:, list_tool:)
104
+ Class.new(ActiveScaffold::MCP::ListViewsTool).tap do |tool_class|
105
+ configure_tool_class(tool_class, controller_class:, action_name:, core_config:, mcp_config:, settings:)
106
+ tool_class.tool_name("#{list_tool.tool_name}_views")
107
+ tool_class.title("List views for #{tool_resource_label(mcp_config, settings)}")
108
+ tool_class.description(
109
+ "List all named views available to the current user for `#{list_tool.tool_name}`. " \
110
+ "Pass a returned `value` to its `list_view` argument; omit `list_view` to use the default columns."
111
+ )
112
+ include_config_list(tool_class, core_config)
113
+ tool_class.read_only
114
+ include_extensions(tool_class, settings)
115
+ end
116
+ end
117
+
118
+ def add_column_filter_properties(tool_class, core_config, settings, mappings)
119
+ configured_filter_columns(core_config, settings).each do |column_name, operators|
120
+ column = core_config._columns_hash[column_name.to_s]
121
+ next unless column
122
+
123
+ Array(operators).each do |operator|
124
+ add_operator_properties(tool_class, column_name, column, normalize_operator(operator), mappings)
125
+ end
126
+ end
127
+ end
128
+
129
+ def configured_filter_columns(core_config, settings)
130
+ result = case settings[:filter_columns]
131
+ when Hash
132
+ settings[:filter_columns].transform_keys(&:to_sym)
133
+ when nil
134
+ {}
135
+ else
136
+ Array(settings[:filter_columns]).to_h { |name| [name.to_sym, :equal] }
137
+ end
138
+
139
+ settings.fetch(:filter_column_sets, {}).each do |set_name, operators|
140
+ columns_for_set(core_config, set_name).each { |name| result[name] ||= operators }
141
+ end
142
+ add_polymorphic_type_columns(core_config, result)
143
+ result
144
+ end
145
+
146
+ def add_polymorphic_type_columns(core_config, filter_columns)
147
+ model = core_config.model
148
+ return unless model.respond_to?(:reflect_on_all_associations)
149
+
150
+ model.reflect_on_all_associations(:belongs_to).select(&:polymorphic?).each do |association|
151
+ foreign_key = association.foreign_key.to_sym
152
+ foreign_type = association.foreign_type.to_sym
153
+ next unless filter_columns.key?(foreign_key)
154
+ next unless core_config._columns_hash.key?(foreign_type.to_s)
155
+
156
+ filter_columns[foreign_type] ||= filter_columns[foreign_key]
157
+ end
158
+ end
159
+
160
+ def columns_for_set(core_config, set_name)
161
+ columns = core_config._columns_hash.values
162
+ selected_columns = case set_name.to_sym
163
+ when :foreign_keys
164
+ columns.select { |column| column.name.end_with?("_id") }
165
+ when :dates
166
+ columns.select { |column| column.type.to_sym == :date }
167
+ when :timestamps
168
+ columns.select { |column| %i[datetime timestamp timestamptz].include?(column.type.to_sym) }
169
+ else
170
+ raise ArgumentError, "unknown MCP filter column set: #{set_name}"
171
+ end
172
+ selected_columns.map { |column| column.name.to_sym }
173
+ end
174
+
175
+ def normalize_operator(operator)
176
+ operator = operator.to_sym
177
+ OPERATOR_ALIASES.fetch(operator, operator)
178
+ end
179
+
180
+ def add_operator_properties(tool_class, column_name, column, operator, mappings)
181
+ case operator
182
+ when :equal, :not_equal
183
+ property_name = operator == :equal ? column_name : :"#{column_name}_not"
184
+ add_scalar_property(tool_class, property_name, column, "Filter by #{column_name}")
185
+ mappings[property_name.to_sym] = {target: column_name.to_sym, operator: operator}
186
+ when :in, :not_in
187
+ property_name = operator == :in ? :"#{column_name}_in" : :"#{column_name}_not_in"
188
+ tool_class.collection property_name,
189
+ type: json_type(column),
190
+ description: "Filter #{column_name} by a list of values"
191
+ mappings[property_name] = {target: column_name.to_sym, operator: operator}
192
+ when :between, :not_between
193
+ prefix = operator == :between ? column_name : :"#{column_name}_not"
194
+ %i[from to].each do |endpoint|
195
+ property_name = :"#{prefix}_#{endpoint}"
196
+ add_scalar_property(tool_class, property_name, column, "#{endpoint.to_s.humanize} value for #{column_name}")
197
+ mappings[property_name] = {
198
+ target: column_name.to_sym,
199
+ operator: :"#{'not_' if operator == :not_between}between_#{endpoint}"
200
+ }
201
+ end
202
+ when :null, :not_null
203
+ property_name = operator == :null ? :"#{column_name}_null" : :"#{column_name}_not_null"
204
+ tool_class.property property_name,
205
+ type: "boolean",
206
+ description: "Match records where #{column_name} is #{'not ' if operator == :not_null}null"
207
+ mappings[property_name] = {target: column_name.to_sym, operator: operator}
208
+ else
209
+ raise ArgumentError, "unsupported MCP filter operator for #{column_name}: #{operator}"
210
+ end
211
+ end
212
+
213
+ def add_scalar_property(tool_class, property_name, column, description)
214
+ tool_class.property(property_name, **schema_options(column), description: description)
215
+ end
216
+
217
+ def add_named_filter_properties(tool_class, action_config, settings, mappings)
218
+ selected_filters(action_config, settings[:filters]).each do |filter|
219
+ property_name = filter.name.to_sym
220
+ if mappings.key?(property_name)
221
+ raise ArgumentError, "named list filter #{filter.name} conflicts with an MCP column filter"
222
+ end
223
+
224
+ option_names = filter.map { |option| option.name.to_s }
225
+ tool_class.property property_name,
226
+ type: "string",
227
+ description: named_filter_description(filter),
228
+ default: filter.default_option&.to_s,
229
+ enum: option_names
230
+ mappings[property_name] = {target: filter.name, operator: :equal, source: :named_filter}
231
+ end
232
+ end
233
+
234
+ def named_filter_description(filter)
235
+ sentences = [filter.description || "Apply the #{filter.name} list filter"]
236
+ sentences << "When omitted, the server uses the default option '#{filter.default_option}'"
237
+ if filter.security_method_set?
238
+ sentences << "This filter may be disallowed in the current context"
239
+ else
240
+ secured_options = filter.select(&:security_method_set?).map { |option| option.name.to_s }
241
+ if secured_options.any?
242
+ sentences << "These options may be disallowed in the current context: #{secured_options.to_sentence}"
243
+ end
244
+ end
245
+ sentences.join(". ")
246
+ end
247
+
248
+ def selected_filters(action_config, configured_filters)
249
+ return [] if configured_filters.nil? || !action_config.respond_to?(:filters)
250
+ return action_config.filters.to_a if configured_filters == :all
251
+
252
+ Array(configured_filters).filter_map { |name| action_config.filters[name] }
253
+ end
254
+
255
+ def add_search_property(tool_class, core_config, settings, mappings)
256
+ return unless search_enabled?(core_config, settings)
257
+
258
+ description = search_property_description(core_config)
259
+ tool_class.include ActiveScaffold::Actions::Search
260
+ tool_class.property :search,
261
+ type: "string",
262
+ description: description
263
+ mappings[:search] = {target: :search, operator: :equal, source: :search, description: description}
264
+ end
265
+
266
+ def search_property_description(core_config)
267
+ columns = core_config.search.columns.visible_columns(skip_authorization: true, flatten: true)
268
+ labels = columns.map { |column| column.label.presence || column.name.to_s.humanize }
269
+ return "Search text across the configured ActiveScaffold search columns" if labels.empty?
270
+
271
+ displayed_labels = labels.first(12)
272
+ remaining_count = labels.length - displayed_labels.length
273
+ description = "Search text across #{displayed_labels.to_sentence}"
274
+ description += " and #{remaining_count} more" if remaining_count.positive?
275
+ description
276
+ end
277
+
278
+ def search_enabled?(core_config, settings)
279
+ settings.fetch(:search, true) && core_config.actions.include?(:search)
280
+ end
281
+
282
+ def add_sorting_properties(tool_class, core_config, action_config)
283
+ columns = core_config.columns.select(&:sortable?)
284
+ column_names = columns.map { |column| column.name.to_s }
285
+ return [] if column_names.empty?
286
+
287
+ tool_class.property :sort,
288
+ type: "string",
289
+ description: "Column to sort by. Allowed columns: #{formatted_column_names(column_names)}. " \
290
+ "#{default_sorting_description(action_config)}.",
291
+ enum: column_names
292
+ tool_class.property :sort_direction,
293
+ type: "string",
294
+ description: "Sort direction used when sort is provided; defaults to asc",
295
+ default: "asc",
296
+ enum: %w[asc desc]
297
+ column_names
298
+ end
299
+
300
+ def formatted_column_names(column_names)
301
+ column_names.map { |name| "`#{name}`" }.to_sentence
302
+ end
303
+
304
+ def default_sorting_description(action_config)
305
+ sorting = action_config.sorting if action_config.respond_to?(:sorting)
306
+ clauses = Array(sorting).map do |column, direction|
307
+ "`#{column.name}` #{direction.to_s.upcase}"
308
+ end
309
+ if clauses.any?
310
+ "When sort is omitted, the ActiveScaffold default sorting is #{clauses.to_sentence}"
311
+ else
312
+ "When sort is omitted, the configured ActiveScaffold default sorting is used"
313
+ end
314
+ end
315
+
316
+ def add_pagination_properties(tool_class, action_config)
317
+ configured_limit = action_config.per_page.to_i
318
+ default_limit = if configured_limit.positive?
319
+ [configured_limit, ActiveScaffold::MCP::ListTool::MAX_LIMIT].min
320
+ else
321
+ ActiveScaffold::MCP::ListTool::DEFAULT_LIMIT
322
+ end
323
+ tool_class.property :limit,
324
+ type: "integer",
325
+ description: "Maximum records to return",
326
+ default: default_limit,
327
+ minimum: 1,
328
+ maximum: ActiveScaffold::MCP::ListTool::MAX_LIMIT
329
+ tool_class.property :page,
330
+ type: "integer",
331
+ description: "Page number",
332
+ default: 1,
333
+ minimum: 1
334
+ end
335
+
336
+ end
337
+ end
338
+ end
339
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "set"
5
+
6
+ module ActiveScaffold
7
+ module MCP
8
+ class ListViewsTool < ListTool
9
+ abstract!
10
+
11
+ def perform
12
+ render(text: JSON.generate(views: mcp_available_list_views))
13
+ end
14
+
15
+ private
16
+
17
+ def mcp_available_list_views
18
+ configured_views = active_scaffold_config.config_list.named_views.filter_map do |view|
19
+ next if view.security_method && !send(view.security_method)
20
+
21
+ {
22
+ label: view.name.to_s,
23
+ value: view.name.to_s,
24
+ source: "configured"
25
+ }.tap do |result|
26
+ result[:description] = view.tooltip.to_s if view.respond_to?(:tooltip) && view.tooltip.present?
27
+ end
28
+ end
29
+ configured_values = configured_views.to_set { |view| view[:value] }
30
+ saved_views = config_list_named_views.filter_map do |item|
31
+ label, value = Array(item)
32
+ value ||= label
33
+ next if configured_values.include?(value.to_s)
34
+
35
+ {
36
+ label: label.to_s,
37
+ value: value.to_s,
38
+ source: "saved"
39
+ }
40
+ end
41
+ configured_views + saved_views
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveScaffold
4
+ module MCP
5
+ class Presenter
6
+ attr_reader :tool
7
+
8
+ def initialize(tool)
9
+ @tool = tool
10
+ end
11
+
12
+ def presentable_column?(column)
13
+ return true if column.association || respond_to?(column_method_name(column), true)
14
+ return false unless model.public_method_defined?(column.name)
15
+
16
+ model.instance_method(column.name).parameters.none? { |type, _| type == :req || type == :keyreq }
17
+ end
18
+
19
+ def record(record, columns, counts = nil)
20
+ columns.each_with_object({}) do |column, result|
21
+ result[column.name] = column_value(record, column, counts)
22
+ end
23
+ end
24
+
25
+ protected
26
+
27
+ delegate :active_scaffold_config, :current_user, :params, to: :tool
28
+
29
+ private
30
+
31
+ def column_method_name(column)
32
+ :"#{column.name}_column"
33
+ end
34
+
35
+ def column_value(record, column, counts)
36
+ column_method = column_method_name(column)
37
+ return send(column_method, record, column) if respond_to?(column_method, true)
38
+ return association_value(record, record.public_send(column.association.name), column, counts) if column.association
39
+
40
+ record.public_send(column.name)
41
+ end
42
+
43
+ def association_value(record, value, column, counts)
44
+ if column.association.collection?
45
+ collection_association_value(record, value, column, counts)
46
+ elsif value
47
+ associated_record_hash(value, polymorphic: column.association.polymorphic?)
48
+ end
49
+ end
50
+
51
+ def collection_association_value(record, value, column, counts)
52
+ associated_limit = column.associated_limit
53
+ associated_number = column.associated_number?
54
+
55
+ if associated_limit.nil?
56
+ return value.map { |associated_record| associated_record_hash(associated_record) }
57
+ end
58
+
59
+ count = association_size(record, value, column, counts) if associated_number
60
+ return associated_number ? count : nil if associated_limit.zero?
61
+
62
+ loaded_limit = associated_number ? associated_limit : associated_limit + 1
63
+ associated_records = limited_associated_records(value, loaded_limit)
64
+ more_records = associated_number ? count > associated_limit : associated_records.size > associated_limit
65
+ associated_records = associated_records.first(associated_limit)
66
+ records = associated_records.map { |associated_record| associated_record_hash(associated_record) }
67
+ displayed_count =
68
+ if associated_number
69
+ count
70
+ elsif more_records
71
+ ">#{associated_limit}"
72
+ else
73
+ records.size
74
+ end
75
+
76
+ {
77
+ (more_records ? :first_records : :records) => records,
78
+ count: displayed_count
79
+ }
80
+ end
81
+
82
+ def limited_associated_records(value, limit)
83
+ if value.respond_to?(:loaded?) && value.loaded?
84
+ value.first(limit)
85
+ elsif value.respond_to?(:limit)
86
+ value.limit(limit).to_a
87
+ else
88
+ value.first(limit)
89
+ end
90
+ end
91
+
92
+ def association_size(_record, value, column, counts)
93
+ return counts[column.name] if counts&.key?(column.name)
94
+
95
+ value.size
96
+ end
97
+
98
+ def associated_record_hash(record, polymorphic: false)
99
+ result = {id: record.id, label: record.to_label}
100
+ result[:model] = record.class.model_name.human if polymorphic
101
+ result
102
+ end
103
+
104
+ def model
105
+ active_scaffold_config.model
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveScaffold
4
+ module MCP
5
+ module ProfileUser
6
+ class InvalidReference < ArgumentError; end
7
+
8
+ module_function
9
+
10
+ def resolve(reference)
11
+ class_name, separator, id = reference.to_s.rpartition("#")
12
+ if separator.empty? || class_name.empty? || id.empty?
13
+ raise InvalidReference, "MCP_USER must use CLASS#ID, for example: User#201"
14
+ end
15
+
16
+ user_class = class_name.safe_constantize
17
+ raise InvalidReference, "MCP_USER class does not exist: #{class_name}" unless user_class
18
+
19
+ unless user_class.respond_to?(:find)
20
+ raise InvalidReference, "MCP_USER class must respond to find: #{class_name}"
21
+ end
22
+
23
+ user_class.find(id)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveScaffold
4
+ module MCP
5
+ class SettingsValidator
6
+ class InvalidSetting < ArgumentError; end
7
+
8
+ COLUMN_SETTING = %i[columns].freeze
9
+ COMMON_SETTINGS = %i[description label modules name presenter presenter_modules].freeze
10
+ LIST_SETTINGS = %i[columns filter_columns filter_column_sets filters search].freeze
11
+ SHOW_SETTINGS = %i[columns lookup_columns].freeze
12
+
13
+ def validate!(action_name, settings)
14
+ settings.each do |setting, value|
15
+ next if valid?(action_name, setting, value)
16
+
17
+ raise InvalidSetting, "unsupported MCP setting for #{action_name}: #{setting}"
18
+ end
19
+
20
+ settings
21
+ end
22
+
23
+ def valid?(action_name, setting, value = nil)
24
+ setting = setting.to_sym
25
+ return valid_common_setting?(setting, value) if COMMON_SETTINGS.include?(setting)
26
+
27
+ validator = :"valid_settings_for_#{action_name}?"
28
+ return true unless respond_to?(validator, true)
29
+
30
+ send(validator, setting, value)
31
+ end
32
+
33
+ private
34
+
35
+ def valid_common_setting?(setting, value)
36
+ case setting
37
+ when :description, :label, :name
38
+ (value.is_a?(String) || value.is_a?(Symbol)) && value.to_s.present?
39
+ when :modules, :presenter_modules
40
+ Array(value).all? { |extension| extension.is_a?(Module) }
41
+ when :presenter
42
+ value.is_a?(Class)
43
+ end
44
+ end
45
+
46
+ def valid_settings_for_list?(setting, value)
47
+ return [true, false].include?(value) if setting == :search
48
+
49
+ LIST_SETTINGS.include?(setting)
50
+ end
51
+
52
+ def valid_settings_for_show?(setting, _value)
53
+ SHOW_SETTINGS.include?(setting)
54
+ end
55
+
56
+ def valid_settings_for_create?(setting, _value)
57
+ COLUMN_SETTING.include?(setting)
58
+ end
59
+
60
+ def valid_settings_for_update?(setting, _value)
61
+ COLUMN_SETTING.include?(setting)
62
+ end
63
+
64
+ def valid_settings_for_delete?(_setting, _value)
65
+ false
66
+ end
67
+
68
+ def valid_settings_for_search?(_setting, _value)
69
+ false
70
+ end
71
+
72
+ def valid_settings_for_nested?(_setting, _value)
73
+ false
74
+ end
75
+
76
+ def valid_settings_for_subform?(_setting, _value)
77
+ false
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ActiveScaffold
6
+ module MCP
7
+ class ShowTool < Tool
8
+ abstract!
9
+
10
+ class_attribute :mcp_lookup_column_names, instance_accessor: false, default: [].freeze
11
+ class_attribute :mcp_primary_key_name, instance_accessor: false
12
+
13
+ validate :exactly_one_lookup_argument
14
+
15
+ def perform
16
+ return unless resolve_lookup_id
17
+
18
+ do_show
19
+ render(text: JSON.generate(record: mcp_record_hash(@record, mcp_output_columns)))
20
+ rescue ActiveRecord::RecordNotFound
21
+ report_error("No record found for id #{params[:id].inspect}")
22
+ end
23
+
24
+ private
25
+
26
+ def tool_authorized?
27
+ super && show_authorized?
28
+ end
29
+
30
+ def mcp_default_output_columns
31
+ active_scaffold_config.show.columns.visible_columns(flatten: true)
32
+ end
33
+
34
+ def exactly_one_lookup_argument
35
+ provided_names = lookup_argument_names.select { |name| mcp_argument_provided?(name) }
36
+ return if provided_names.one?
37
+
38
+ errors.add(:base, "Provide exactly one of #{lookup_argument_names.to_sentence}")
39
+ end
40
+
41
+ def lookup_argument_names
42
+ [:id, *self.class.mcp_lookup_column_names]
43
+ end
44
+
45
+ def resolve_lookup_id
46
+ return true if mcp_argument_provided?(:id)
47
+
48
+ column_name = self.class.mcp_lookup_column_names.find { |name| mcp_argument_provided?(name) }
49
+ # Resolve exact identifiers within the base scope, without list filters
50
+ # (including defaults) hiding an otherwise showable record.
51
+ ids = beginning_of_chain.where(column_name => public_send(column_name))
52
+ .limit(2)
53
+ .pluck(self.class.mcp_primary_key_name)
54
+ if ids.empty?
55
+ report_error("No record found for #{column_name} #{public_send(column_name).inspect}")
56
+ return false
57
+ end
58
+ if ids.many?
59
+ report_error("More than one record found for #{column_name} #{public_send(column_name).inspect}; use id instead")
60
+ return false
61
+ end
62
+
63
+ params[:id] = ids.first
64
+ true
65
+ end
66
+ end
67
+ end
68
+ end