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,297 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveScaffold
|
|
4
|
+
module Config
|
|
5
|
+
class MCP < Base
|
|
6
|
+
def initialize(core_config)
|
|
7
|
+
super
|
|
8
|
+
@actions = self.class.duplicate_actions(self.class.actions)
|
|
9
|
+
self.filter_column_sets = self.class.filter_column_sets
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Global defaults. These are available through config.mcp in an
|
|
13
|
+
# ActiveScaffold.defaults block.
|
|
14
|
+
cattr_accessor :actions, instance_accessor: false, default: {}.freeze
|
|
15
|
+
cattr_accessor :filter_column_sets, instance_accessor: false, default: {}.freeze
|
|
16
|
+
cattr_accessor :base_tool, instance_accessor: false,
|
|
17
|
+
default: -> { "ApplicationMCPTool".safe_constantize || ActionMCP::Tool }
|
|
18
|
+
cattr_accessor :settings_validator, instance_accessor: false,
|
|
19
|
+
default: ActiveScaffold::MCP::SettingsValidator
|
|
20
|
+
cattr_accessor :tool_generator, instance_accessor: false,
|
|
21
|
+
default: ActiveScaffold::MCP::GeneratorFactory.new
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
alias unvalidated_actions= actions=
|
|
25
|
+
alias filter_columns_sets filter_column_sets
|
|
26
|
+
alias filter_columns_sets= filter_column_sets=
|
|
27
|
+
|
|
28
|
+
def actions=(value)
|
|
29
|
+
self.unvalidated_actions = normalize_actions(value)
|
|
30
|
+
end
|
|
31
|
+
private :unvalidated_actions=
|
|
32
|
+
|
|
33
|
+
def resolve_tool(name)
|
|
34
|
+
configured_tool = public_send(:"#{name}_tool")
|
|
35
|
+
configured_tool = configured_tool.call if configured_tool.respond_to?(:call)
|
|
36
|
+
configured_tool.is_a?(String) ? configured_tool.constantize : configured_tool
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def resolved_settings_validator
|
|
40
|
+
configured_validator = settings_validator
|
|
41
|
+
if !defined?(@resolved_settings_validator_source) || @resolved_settings_validator_source != configured_validator
|
|
42
|
+
@resolved_settings_validator_source = configured_validator
|
|
43
|
+
@resolved_settings_validator = configured_validator.is_a?(Class) ? configured_validator.new : configured_validator
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@resolved_settings_validator
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def validate_mcp_setting!(action_name, setting, value)
|
|
50
|
+
resolved_settings_validator.validate!(action_name, setting.to_sym => value)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def mcp_setting_for(action_name, setting)
|
|
54
|
+
action_settings = actions.fetch(:all, {}).merge(actions.fetch(action_name.to_sym, {}))
|
|
55
|
+
action_settings[setting.to_sym]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def set_mcp_setting(action_name, setting, value)
|
|
59
|
+
action_name = action_name.to_sym
|
|
60
|
+
setting = setting.to_sym
|
|
61
|
+
updated_actions = actions.transform_keys(&:to_sym)
|
|
62
|
+
action_settings = updated_actions.fetch(action_name, {}).merge(setting => value)
|
|
63
|
+
updated_actions[action_name] = normalize_settings(action_settings)
|
|
64
|
+
self.unvalidated_actions = updated_actions
|
|
65
|
+
value
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def expose(action_name, settings = nil)
|
|
69
|
+
action_name = action_name.to_sym
|
|
70
|
+
updated_actions = actions.transform_keys(&:to_sym)
|
|
71
|
+
unless settings.nil? && updated_actions.key?(action_name)
|
|
72
|
+
updated_actions[action_name] = normalize_action_settings(action_name, settings)
|
|
73
|
+
end
|
|
74
|
+
self.unvalidated_actions = updated_actions
|
|
75
|
+
|
|
76
|
+
action_config = ActiveScaffold::MCP::ActionConfig.new(self, action_name)
|
|
77
|
+
yield action_config if block_given?
|
|
78
|
+
action_config
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# These methods are also used by per-controller MCP config instances,
|
|
82
|
+
# keeping global defaults and local configuration normalized identically.
|
|
83
|
+
def normalize_actions(value)
|
|
84
|
+
return {all: {}} if all?(value)
|
|
85
|
+
|
|
86
|
+
case value
|
|
87
|
+
when nil
|
|
88
|
+
{}
|
|
89
|
+
when Hash
|
|
90
|
+
value.each_with_object({}) do |(action_name, settings), result|
|
|
91
|
+
result[action_name.to_sym] = normalize_action_settings(action_name, settings)
|
|
92
|
+
end
|
|
93
|
+
else
|
|
94
|
+
Array(value).each_with_object({}) do |action_name, result|
|
|
95
|
+
result[action_name.to_sym] = {}
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def normalize_action_settings(action_name, value)
|
|
101
|
+
settings = case value
|
|
102
|
+
when nil, true
|
|
103
|
+
{}
|
|
104
|
+
when Array
|
|
105
|
+
{columns: normalize_names(value)}
|
|
106
|
+
when Hash
|
|
107
|
+
value.to_h.transform_keys(&:to_sym)
|
|
108
|
+
else
|
|
109
|
+
raise ArgumentError, "settings for #{action_name} must be an array or hash"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
resolved_settings_validator.validate!(action_name, settings)
|
|
113
|
+
normalize_settings(settings)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def normalize_settings(settings)
|
|
117
|
+
settings.each_with_object({}) do |(name, value), result|
|
|
118
|
+
result[name] = case name
|
|
119
|
+
when :description, :label, :name
|
|
120
|
+
value.to_s
|
|
121
|
+
when :columns, :lookup_columns
|
|
122
|
+
normalize_names(value)
|
|
123
|
+
when :filter_columns
|
|
124
|
+
value.is_a?(Hash) ? normalize_operator_map(value) : normalize_names(value)
|
|
125
|
+
when :filter_column_sets
|
|
126
|
+
normalize_filter_column_sets(value)
|
|
127
|
+
when :filters
|
|
128
|
+
all?(value) ? :all : normalize_names(value)
|
|
129
|
+
when :modules, :presenter_modules
|
|
130
|
+
Array(value).compact
|
|
131
|
+
when :presenter
|
|
132
|
+
value
|
|
133
|
+
else
|
|
134
|
+
duplicate_setting(value)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def duplicate_actions(configured_actions)
|
|
140
|
+
configured_actions.each_with_object({}) do |(action_name, settings), result|
|
|
141
|
+
result[action_name] = duplicate_settings(settings)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def duplicate_settings(settings)
|
|
146
|
+
settings.each_with_object({}) do |(name, value), result|
|
|
147
|
+
result[name] = case name.to_sym
|
|
148
|
+
when :modules, :presenter_modules
|
|
149
|
+
Array(value).dup
|
|
150
|
+
when :presenter
|
|
151
|
+
value
|
|
152
|
+
else
|
|
153
|
+
duplicate_setting(value)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def normalize_operator_map(value)
|
|
159
|
+
return {} if value.nil?
|
|
160
|
+
|
|
161
|
+
value.to_h.each_with_object({}) do |(name, operators), result|
|
|
162
|
+
result[name.to_sym] = operators.is_a?(Array) ? normalize_names(operators) : operators.to_sym
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def normalize_filter_column_sets(value)
|
|
167
|
+
return {} if value.nil?
|
|
168
|
+
|
|
169
|
+
unless value.is_a?(Hash)
|
|
170
|
+
raise ArgumentError,
|
|
171
|
+
"filter_column_sets must be a hash mapping set names to operators, " \
|
|
172
|
+
"for example: {foreign_keys: :in, timestamps: :range}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
normalize_operator_map(value)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def method_missing(name, *args)
|
|
179
|
+
if args.empty? && mcp_action?(name)
|
|
180
|
+
ActiveScaffold::MCP::ActionConfig.new(self, name)
|
|
181
|
+
else
|
|
182
|
+
super
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def respond_to_missing?(name, include_all = false)
|
|
187
|
+
mcp_action?(name) || super
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def mcp_action?(name)
|
|
191
|
+
ActiveScaffold::Config::Core.config_class?(name) || actions.key?(name.to_sym)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def all?(value)
|
|
195
|
+
value == :all || value == "all"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def normalize_names(value)
|
|
199
|
+
Array(value).flatten.compact.map(&:to_sym).uniq
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def duplicate_setting(value)
|
|
203
|
+
value.respond_to?(:deep_dup) ? value.deep_dup : value
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private :mcp_action?, :all?, :normalize_names, :duplicate_setting
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
attr_reader :actions, :filter_column_sets
|
|
210
|
+
attr_writer :label, :name
|
|
211
|
+
alias exposed_actions actions
|
|
212
|
+
alias filter_columns_sets filter_column_sets
|
|
213
|
+
|
|
214
|
+
def label(core: @core)
|
|
215
|
+
@label ? as_(@label, count: 2) : core.label(count: 2)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def name
|
|
219
|
+
(@name || core.model.model_name.route_key).to_s
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def actions=(value)
|
|
223
|
+
@actions = self.class.normalize_actions(value)
|
|
224
|
+
end
|
|
225
|
+
alias exposed_actions= actions=
|
|
226
|
+
|
|
227
|
+
def filter_column_sets=(value)
|
|
228
|
+
@filter_column_sets = self.class.normalize_filter_column_sets(value)
|
|
229
|
+
end
|
|
230
|
+
alias filter_columns_sets= filter_column_sets=
|
|
231
|
+
|
|
232
|
+
def expose(action_name, settings = nil)
|
|
233
|
+
action_name = action_name.to_sym
|
|
234
|
+
unless settings.nil? && @actions.key?(action_name)
|
|
235
|
+
@actions[action_name] = self.class.normalize_action_settings(action_name, settings)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
action_config = ActiveScaffold::MCP::ActionConfig.new(self, action_name)
|
|
239
|
+
yield action_config if block_given?
|
|
240
|
+
action_config
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def exposed?(action_name)
|
|
244
|
+
@actions.key?(:all) || @actions.key?(action_name.to_sym)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def settings_for(action_name)
|
|
248
|
+
return unless exposed?(action_name)
|
|
249
|
+
|
|
250
|
+
settings = self.class.duplicate_settings(
|
|
251
|
+
@actions.fetch(:all, {}).merge(@actions.fetch(action_name.to_sym, {}))
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
if action_name.to_sym == :list && !settings.key?(:filter_column_sets) && @filter_column_sets.any?
|
|
255
|
+
settings[:filter_column_sets] = @filter_column_sets.deep_dup
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
settings
|
|
259
|
+
end
|
|
260
|
+
alias action_settings settings_for
|
|
261
|
+
|
|
262
|
+
def mcp_setting_for(action_name, setting)
|
|
263
|
+
settings_for(action_name)&.[](setting.to_sym)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def set_mcp_setting(action_name, setting, value)
|
|
267
|
+
action_name = action_name.to_sym
|
|
268
|
+
setting = setting.to_sym
|
|
269
|
+
settings = @actions.fetch(action_name, {}).merge(setting => value)
|
|
270
|
+
@actions[action_name] = self.class.normalize_settings(settings)
|
|
271
|
+
value
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def validate_mcp_setting!(action_name, setting, value)
|
|
275
|
+
self.class.validate_mcp_setting!(action_name, setting, value)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def method_missing(name, *args)
|
|
279
|
+
if args.empty? && mcp_action?(name)
|
|
280
|
+
ActiveScaffold::MCP::ActionConfig.new(self, name)
|
|
281
|
+
else
|
|
282
|
+
super
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def respond_to_missing?(name, include_all = false)
|
|
287
|
+
mcp_action?(name) || super
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
private
|
|
291
|
+
|
|
292
|
+
def mcp_action?(name)
|
|
293
|
+
ActiveScaffold::Config::Core.config_class?(name) || @actions.key?(name.to_sym)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveScaffold
|
|
4
|
+
module MCP
|
|
5
|
+
class ActionConfig
|
|
6
|
+
attr_reader :action_name
|
|
7
|
+
|
|
8
|
+
def initialize(owner, action_name)
|
|
9
|
+
@owner = owner
|
|
10
|
+
@action_name = action_name.to_sym
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def [](setting)
|
|
14
|
+
@owner.mcp_setting_for(@action_name, setting)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def []=(setting, value)
|
|
18
|
+
@owner.validate_mcp_setting!(@action_name, setting, value)
|
|
19
|
+
@owner.set_mcp_setting(@action_name, setting, value)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def method_missing(name, *args)
|
|
23
|
+
method_name = name.to_s
|
|
24
|
+
if method_name.end_with?("=") && args.length == 1
|
|
25
|
+
self[method_name.delete_suffix("=")] = args.first
|
|
26
|
+
elsif args.empty?
|
|
27
|
+
self[name]
|
|
28
|
+
else
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def respond_to_missing?(_name, _include_all = false)
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveScaffold
|
|
4
|
+
module MCP
|
|
5
|
+
class Engine < Rails::Engine
|
|
6
|
+
rake_tasks do
|
|
7
|
+
load File.expand_path("../../tasks/active_scaffold_mcp_tasks.rake", __dir__)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
initializer "active_scaffold_mcp.initialize", after: "initialize_active_scaffold" do
|
|
11
|
+
ActiveScaffold.defaults do |config|
|
|
12
|
+
config.actions << :mcp unless config.actions.include?(:mcp)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
config.to_prepare do
|
|
17
|
+
if Rails.application.config.reloading_enabled?
|
|
18
|
+
ActiveScaffold::MCP.reload_generated_tools!
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveScaffold
|
|
4
|
+
module MCP
|
|
5
|
+
module FilteredTools
|
|
6
|
+
def filtered_tools
|
|
7
|
+
super.select do |entry|
|
|
8
|
+
tool_class = entry.klass
|
|
9
|
+
!tool_class.respond_to?(:tool_authorized?) || tool_class.tool_authorized?
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
unless ActionMCP::Configuration.ancestors.include?(ActiveScaffold::MCP::FilteredTools)
|
|
17
|
+
ActionMCP::Configuration.prepend(ActiveScaffold::MCP::FilteredTools)
|
|
18
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveScaffold
|
|
4
|
+
module MCP
|
|
5
|
+
class GeneratorFactory
|
|
6
|
+
class GeneratorNotFound < StandardError; end
|
|
7
|
+
|
|
8
|
+
Rule = Data.define(:generator, :attributes, :position) do
|
|
9
|
+
def matches?(values)
|
|
10
|
+
attributes.all? { |name, expected| expected.nil? || values[name] == expected }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def specificity
|
|
14
|
+
attributes.values.count { |value| !value.nil? }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@action_generators = {}
|
|
20
|
+
@crud_rules = []
|
|
21
|
+
@custom_action_rules = []
|
|
22
|
+
@registration_position = 0
|
|
23
|
+
register ActiveScaffold::MCP::ListToolGenerator, actions: :list
|
|
24
|
+
register ActiveScaffold::MCP::ShowToolGenerator, actions: :show
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def register(generator, actions:)
|
|
28
|
+
Array(actions).each { |action_name| @action_generators[action_name.to_sym] = generator }
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def register_default(generator, crud_type: nil, columns: nil)
|
|
33
|
+
add_rule(@crud_rules, generator, crud_type: crud_type&.to_sym, columns: columns)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# This registry is ready for ActiveScaffold's future custom_actions
|
|
37
|
+
# configuration. nil values are wildcards.
|
|
38
|
+
def register_custom(generator, submit_method: nil, columns: nil, destructive: nil)
|
|
39
|
+
add_rule(
|
|
40
|
+
@custom_action_rules,
|
|
41
|
+
generator,
|
|
42
|
+
submit_method: submit_method&.to_sym,
|
|
43
|
+
columns: columns,
|
|
44
|
+
destructive: destructive
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def generate_tools_for(controller_class, mcp_config)
|
|
49
|
+
exposed_action_names(mcp_config).flat_map do |action_name|
|
|
50
|
+
action_config, custom = action_config_for(mcp_config.core, action_name)
|
|
51
|
+
generator = generator_for(action_name, action_config, custom: custom)
|
|
52
|
+
unless generator
|
|
53
|
+
raise GeneratorNotFound,
|
|
54
|
+
"no MCP tool generator registered for #{controller_class.name}##{action_name}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
result = generate_tool(
|
|
58
|
+
generator,
|
|
59
|
+
controller_class: controller_class,
|
|
60
|
+
action_name: action_name,
|
|
61
|
+
action_config: action_config,
|
|
62
|
+
mcp_config: mcp_config,
|
|
63
|
+
settings: mcp_config.settings_for(action_name)
|
|
64
|
+
)
|
|
65
|
+
Array(result).compact
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def add_rule(collection, generator, **attributes)
|
|
72
|
+
@registration_position += 1
|
|
73
|
+
collection << Rule.new(generator:, attributes:, position: @registration_position)
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def exposed_action_names(mcp_config)
|
|
78
|
+
names = mcp_config.actions.keys - [:all]
|
|
79
|
+
available_names = (
|
|
80
|
+
mcp_config.core.actions.to_a + custom_action_names(mcp_config.core)
|
|
81
|
+
).uniq
|
|
82
|
+
return names & available_names unless mcp_config.actions.key?(:all)
|
|
83
|
+
|
|
84
|
+
(names + available_names).uniq & available_names
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def action_config_for(core_config, action_name)
|
|
88
|
+
custom_config = custom_action_config_for(core_config, action_name)
|
|
89
|
+
return [custom_config, true] if custom_config
|
|
90
|
+
|
|
91
|
+
action_config = core_config[action_name] if core_config.actions.include?(action_name.to_sym)
|
|
92
|
+
[action_config, false]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def generator_for(action_name, action_config, custom:)
|
|
96
|
+
explicit_generator = @action_generators[action_name.to_sym]
|
|
97
|
+
return explicit_generator if explicit_generator
|
|
98
|
+
|
|
99
|
+
if custom
|
|
100
|
+
matching_rule(
|
|
101
|
+
@custom_action_rules,
|
|
102
|
+
submit_method: value_for(action_config, :submit_method)&.to_sym,
|
|
103
|
+
columns: columns?(action_config),
|
|
104
|
+
destructive: value_for(action_config, :destructive)
|
|
105
|
+
)
|
|
106
|
+
elsif action_config
|
|
107
|
+
matching_rule(
|
|
108
|
+
@crud_rules,
|
|
109
|
+
crud_type: value_for(action_config, :crud_type)&.to_sym,
|
|
110
|
+
columns: columns?(action_config)
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def matching_rule(rules, **values)
|
|
116
|
+
rules.select { |rule| rule.matches?(values) }
|
|
117
|
+
.max_by { |rule| [rule.specificity, rule.position] }
|
|
118
|
+
&.generator
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def columns?(action_config)
|
|
122
|
+
return false unless action_config.respond_to?(:columns)
|
|
123
|
+
|
|
124
|
+
columns = action_config.columns
|
|
125
|
+
!columns.respond_to?(:empty?) || !columns.empty?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def value_for(config, name)
|
|
129
|
+
config.public_send(name) if config.respond_to?(name)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def custom_action_names(core_config)
|
|
133
|
+
custom_actions = value_for(core_config, :custom_actions)
|
|
134
|
+
return [] unless custom_actions
|
|
135
|
+
|
|
136
|
+
if custom_actions.respond_to?(:keys)
|
|
137
|
+
custom_actions.keys.map(&:to_sym)
|
|
138
|
+
elsif custom_actions.respond_to?(:names)
|
|
139
|
+
custom_actions.names.map(&:to_sym)
|
|
140
|
+
else
|
|
141
|
+
[]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def custom_action_config_for(core_config, action_name)
|
|
146
|
+
custom_actions = value_for(core_config, :custom_actions)
|
|
147
|
+
return unless custom_actions&.respond_to?(:[])
|
|
148
|
+
|
|
149
|
+
custom_actions[action_name.to_sym] || custom_actions[action_name.to_s]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def generate_tool(generator, **arguments)
|
|
153
|
+
generator.generate_tool_for(**arguments)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ActiveScaffold
|
|
6
|
+
module MCP
|
|
7
|
+
class ListTool < Tool
|
|
8
|
+
DEFAULT_LIMIT = 25
|
|
9
|
+
MAX_LIMIT = 100
|
|
10
|
+
|
|
11
|
+
abstract!
|
|
12
|
+
|
|
13
|
+
include ActiveScaffold::Actions::List
|
|
14
|
+
|
|
15
|
+
def find_page_options
|
|
16
|
+
super.merge(
|
|
17
|
+
pagination: true,
|
|
18
|
+
per_page: limit.to_i.clamp(1, MAX_LIMIT),
|
|
19
|
+
page: [page.to_i, 1].max
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def perform
|
|
24
|
+
if mcp_argument_provided?(:search)
|
|
25
|
+
store_search_params_into_session
|
|
26
|
+
do_search
|
|
27
|
+
end
|
|
28
|
+
set_default_sorting if mcp_argument_provided?(:list_view)
|
|
29
|
+
do_list
|
|
30
|
+
return if report_disallowed_filters
|
|
31
|
+
|
|
32
|
+
columns = mcp_output_columns
|
|
33
|
+
render(text: JSON.generate(
|
|
34
|
+
records: @records.map { |record| mcp_record_hash(record, columns) },
|
|
35
|
+
page: @page.number,
|
|
36
|
+
total_count: @page.pager.count,
|
|
37
|
+
total_pages: @page.pager.number_of_pages
|
|
38
|
+
))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def build_active_scaffold_params
|
|
44
|
+
super.tap do |active_scaffold_params|
|
|
45
|
+
if mcp_argument_provided?(:list_view)
|
|
46
|
+
active_scaffold_params[:config_list_view] = list_view
|
|
47
|
+
end
|
|
48
|
+
next unless mcp_argument_provided?(:sort)
|
|
49
|
+
|
|
50
|
+
active_scaffold_params[:sort] = sort
|
|
51
|
+
active_scaffold_params[:sort_direction] = sort_direction.presence || "asc"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def report_disallowed_filters
|
|
56
|
+
disallowed_filters = Array(@filter_states).filter_map do |name, state|
|
|
57
|
+
next unless state[:requested] && state[:status] == :disallowed
|
|
58
|
+
|
|
59
|
+
selection = (state[:option]&.name || params[name]).to_s
|
|
60
|
+
["`#{name}`", "`#{name}=#{selection.inspect}`"]
|
|
61
|
+
end
|
|
62
|
+
return false if disallowed_filters.empty?
|
|
63
|
+
|
|
64
|
+
filter_names = disallowed_filters.map(&:first)
|
|
65
|
+
filter_selections = disallowed_filters.map(&:last)
|
|
66
|
+
|
|
67
|
+
report_error(
|
|
68
|
+
"The following requested filter selections could not be applied in this call because they were not allowed " \
|
|
69
|
+
"for its current arguments and context: #{filter_selections.to_sentence}. No records were returned. " \
|
|
70
|
+
"Retry the same call with only #{filter_names.to_sentence} omitted and keep all other arguments unchanged. " \
|
|
71
|
+
"Filter availability is evaluated for each call, so the selection may be allowed with different arguments " \
|
|
72
|
+
"or context."
|
|
73
|
+
)
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def action_not_allowed_message
|
|
78
|
+
return super unless params[:config_list_view].present?
|
|
79
|
+
|
|
80
|
+
"List view #{params[:config_list_view].inspect} is not allowed for the current user."
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# MCP tools render their own result and do not participate in HTTP
|
|
84
|
+
# conditional GET handling. Disabling it also avoids the controller-only
|
|
85
|
+
# request dependency while ActiveScaffold builds a page.
|
|
86
|
+
def conditional_get_support?
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def tool_authorized?
|
|
91
|
+
super && list_authorized? && (!mcp_argument_provided?(:search) || search_authorized?)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def mcp_default_output_columns
|
|
95
|
+
list_columns
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def mcp_configured_output_column_names
|
|
99
|
+
return if mcp_argument_provided?(:list_view)
|
|
100
|
+
|
|
101
|
+
super
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def controller_name
|
|
105
|
+
self.class.active_scaffold_controller_class.controller_name
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def active_scaffold_session_storage_key(id = nil)
|
|
109
|
+
id ||= params[:eid] || self.class.active_scaffold_controller_class.controller_path
|
|
110
|
+
"as:#{id}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|