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,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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveScaffold
4
+ module MCP
5
+ class Flash < ActiveSupport::HashWithIndifferentAccess
6
+ def messages
7
+ values.flatten.compact
8
+ end
9
+ end
10
+ end
11
+ 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