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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +505 -0
- data/lib/active_scaffold/actions/mcp.rb +14 -0
- data/lib/active_scaffold/config/mcp.rb +297 -0
- data/lib/active_scaffold/mcp/action_config.rb +38 -0
- data/lib/active_scaffold/mcp/engine.rb +23 -0
- data/lib/active_scaffold/mcp/filtered_tools.rb +18 -0
- data/lib/active_scaffold/mcp/flash.rb +11 -0
- data/lib/active_scaffold/mcp/generator_factory.rb +157 -0
- data/lib/active_scaffold/mcp/list_tool.rb +114 -0
- data/lib/active_scaffold/mcp/list_tool_generator.rb +339 -0
- data/lib/active_scaffold/mcp/list_views_tool.rb +45 -0
- data/lib/active_scaffold/mcp/presenter.rb +109 -0
- data/lib/active_scaffold/mcp/profile_user.rb +27 -0
- data/lib/active_scaffold/mcp/settings_validator.rb +81 -0
- data/lib/active_scaffold/mcp/show_tool.rb +68 -0
- data/lib/active_scaffold/mcp/show_tool_generator.rb +69 -0
- data/lib/active_scaffold/mcp/tool.rb +254 -0
- data/lib/active_scaffold/mcp/tool_generator.rb +85 -0
- data/lib/active_scaffold/mcp/version.rb +7 -0
- data/lib/active_scaffold/mcp.rb +61 -0
- data/lib/active_scaffold_mcp.rb +8 -0
- data/lib/tasks/active_scaffold_mcp_tasks.rake +11 -0
- metadata +104 -0
|
@@ -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
|