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,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveScaffold
4
+ module MCP
5
+ class ShowToolGenerator < ToolGenerator
6
+ class << self
7
+ def generate_tool_for(controller_class:, action_name:, action_config:, mcp_config:, settings:)
8
+ core_config = mcp_config.core
9
+ primary_key = core_config.model.primary_key
10
+ raise ArgumentError, "cannot generate a show tool without a primary key" if primary_key.blank?
11
+ raise ArgumentError, "composite primary keys are not supported by the show tool" if primary_key.is_a?(Array)
12
+ lookup_columns = Array(settings[:lookup_columns]).map(&:to_sym).uniq - [primary_key.to_sym]
13
+ validate_lookup_columns!(core_config, lookup_columns)
14
+
15
+ Class.new(ActiveScaffold::MCP::ShowTool).tap do |tool_class|
16
+ configure_tool_class(tool_class, controller_class:, action_name:, core_config:, mcp_config:, settings:)
17
+ tool_class.mcp_lookup_column_names = lookup_columns.freeze
18
+ tool_class.mcp_primary_key_name = primary_key.to_sym
19
+ tool_class.include ActiveScaffold::Actions::List if core_config.actions.include?(:list)
20
+ tool_class.include ActiveScaffold::Actions::Show
21
+ tool_class.description(settings[:description] || description(tool_class.title, lookup_columns))
22
+ tool_class.read_only
23
+ tool_class.property :id,
24
+ **schema_options(core_config._columns_hash[primary_key.to_s]),
25
+ description: "Database ID of the #{singular_label(mcp_config)} to return",
26
+ required: lookup_columns.empty?
27
+ mappings = {id: {target: :id, operator: :equal}}
28
+ lookup_columns.each do |column_name|
29
+ tool_class.property column_name,
30
+ **schema_options(core_config._columns_hash[column_name.to_s]),
31
+ description: "#{column_name.to_s.humanize} of the #{singular_label(mcp_config)} to return"
32
+ mappings[column_name] = {target: column_name, operator: :equal}
33
+ end
34
+ tool_class.mcp_param_mappings = mappings.freeze
35
+
36
+ include_extensions(tool_class, settings)
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def validate_lookup_columns!(core_config, lookup_columns)
43
+ unknown_columns = lookup_columns.reject { |name| core_config._columns_hash.key?(name.to_s) }
44
+ return if unknown_columns.empty?
45
+
46
+ raise ArgumentError, "unknown show lookup columns: #{unknown_columns.to_sentence}"
47
+ end
48
+
49
+ def tool_resource_name(mcp_config)
50
+ super.singularize
51
+ end
52
+
53
+ def tool_resource_label(mcp_config, settings)
54
+ super.singularize
55
+ end
56
+
57
+ def description(generated_title, lookup_columns)
58
+ identifiers = ["id", *lookup_columns.map(&:to_s)]
59
+ human_identifiers = identifiers.map { |name| name.humanize.downcase }
60
+ arguments = identifiers.map { |name| "`#{name}`" }
61
+ resource_label = generated_title.delete_prefix("Show ").singularize
62
+ "Retrieve one #{resource_label} by #{human_identifiers.to_sentence(two_words_connector: ' or ', last_word_connector: ' or ')}. " \
63
+ "Use this tool when the user supplies one of these identifiers. " \
64
+ "Pass exactly one of #{arguments.to_sentence(two_words_connector: ' or ', last_word_connector: ' or ')}."
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveScaffold
4
+ module MCP
5
+ class Tool < ActiveScaffold::Config::MCP.resolve_tool(:base)
6
+ abstract!
7
+
8
+ class_attribute :active_scaffold_config, instance_accessor: false
9
+ class_attribute :active_scaffold_controller_class, instance_accessor: false
10
+ class_attribute :mcp_action_name, instance_accessor: false
11
+ class_attribute :mcp_output_column_names, instance_accessor: false
12
+ class_attribute :mcp_param_mappings, instance_accessor: false, default: {}
13
+ class_attribute :mcp_presenter_class, instance_accessor: false, default: ActiveScaffold::MCP::Presenter
14
+
15
+ class << self
16
+ def parameter_filter
17
+ filters = Rails.application&.config&.filter_parameters || []
18
+ return @parameter_filter if @parameter_filter_filters == filters
19
+
20
+ @parameter_filter_filters = filters.dup.freeze
21
+ @parameter_filter = ActiveSupport::ParameterFilter.new(filters)
22
+ end
23
+
24
+ def tool_authorized?(user = ActionMCP::Current.user)
25
+ return true unless respond_to?(:active_scaffold_tool_authorized?)
26
+
27
+ active_scaffold_tool_authorized?(
28
+ controller_class: active_scaffold_controller_class,
29
+ action_name: mcp_action_name,
30
+ user: user
31
+ )
32
+ end
33
+
34
+ # ActiveScaffold action modules declare Rails controller callbacks and
35
+ # helpers when included. MCP tools invoke their operation methods
36
+ # directly, so these declarations intentionally do nothing.
37
+ def before_action(...); end
38
+ def prepend_before_action(...); end
39
+ def after_action(...); end
40
+ def around_action(...); end
41
+ def helper_method(...); end
42
+ def rescue_from(...); end
43
+ end
44
+
45
+ include ActiveScaffold::Finder
46
+ include ActiveScaffold::Constraints
47
+ include ActiveScaffold::Actions::Core
48
+
49
+ around_perform :with_active_scaffold_context
50
+
51
+ def initialize(attributes = {})
52
+ @provided_mcp_arguments = attributes.respond_to?(:keys) ? attributes.keys.map(&:to_s) : []
53
+ super
54
+ end
55
+
56
+ def active_scaffold_config
57
+ @active_scaffold_config ||= begin
58
+ config = self.class.active_scaffold_config
59
+ config.new_user_settings({}, params)
60
+ config.user
61
+ end
62
+ end
63
+
64
+ def params
65
+ @params ||= ::ActionController::Parameters.new(build_active_scaffold_params)
66
+ end
67
+
68
+ def flash
69
+ @flash ||= ActiveScaffold::MCP::Flash.new
70
+ end
71
+
72
+ def action_name
73
+ self.class.mcp_action_name.to_s
74
+ end
75
+
76
+ def active_scaffold_session_storage(_id = nil)
77
+ @active_scaffold_session_storage ||= ActiveSupport::HashWithIndifferentAccess.new
78
+ end
79
+
80
+ def mcp_presenter
81
+ @mcp_presenter ||= self.class.mcp_presenter_class.new(self)
82
+ end
83
+
84
+ private
85
+
86
+ def with_active_scaffold_context
87
+ previous_user_proc = ActiveScaffold::Registry.current_user_proc
88
+ ActiveScaffold::Registry.current_user_proc = -> { current_user }
89
+ if tool_authorized?
90
+ yield
91
+ else
92
+ unauthorized_tool!
93
+ end
94
+ rescue ActiveScaffold::ActionNotAllowed
95
+ report_error(action_not_allowed_message)
96
+ rescue StandardError => exception
97
+ report_active_scaffold_exception(exception)
98
+ raise
99
+ ensure
100
+ ActiveScaffold::Registry.current_user_proc = previous_user_proc
101
+ end
102
+
103
+ def report_active_scaffold_exception(exception)
104
+ arguments = mcp_exception_arguments
105
+ Rails.logger.error(
106
+ "[ActiveScaffold::MCP] #{self.class.tool_name} failed with arguments #{arguments.inspect}: " \
107
+ "#{exception.full_message(highlight: false, order: :top)}"
108
+ )
109
+ notify_active_scaffold_exception(exception) if respond_to?(:notify_active_scaffold_exception, true)
110
+ rescue StandardError => notification_exception
111
+ Rails.logger.error(
112
+ "[ActiveScaffold::MCP] exception notification failed for #{self.class.tool_name}: " \
113
+ "#{notification_exception.full_message(highlight: false, order: :top)}"
114
+ )
115
+ end
116
+
117
+ def action_not_allowed_message
118
+ "The requested #{action_name.humanize.downcase} action is not allowed for the current user or request context."
119
+ end
120
+
121
+ def mcp_exception_arguments
122
+ arguments = if defined?(@_wire_arguments)
123
+ @_wire_arguments
124
+ else
125
+ attributes.compact.merge(@_provided_additional_attributes)
126
+ end
127
+ ActiveScaffold::MCP::Tool.parameter_filter.filter(arguments.deep_dup)
128
+ end
129
+
130
+ # Let the configured base tool customize execution authorization failures.
131
+ # Its implementation replaces this fallback and is responsible for
132
+ # reporting the failure, normally by raising an application-specific error.
133
+ def unauthorized_tool!
134
+ return super if defined?(super)
135
+
136
+ raise ActiveScaffold::ActionNotAllowed
137
+ end
138
+
139
+ def tool_authorized?
140
+ self.class.tool_authorized?(current_user)
141
+ end
142
+
143
+ def build_active_scaffold_params
144
+ filter_values = {}
145
+ ranges = Hash.new { |hash, key| hash[key] = {} }
146
+
147
+ self.class.mcp_param_mappings.each do |property_name, mapping|
148
+ next unless @provided_mcp_arguments.include?(property_name.to_s)
149
+
150
+ value = public_send(property_name)
151
+ target = mapping.fetch(:target).to_s
152
+ case mapping.fetch(:operator)
153
+ when :equal, :in
154
+ filter_values[target] = value
155
+ when :not_equal, :not_in
156
+ filter_values["#{target}!"] = value
157
+ when :between_from, :between_to, :not_between_from, :not_between_to
158
+ ranges[[target, mapping[:operator].to_s.start_with?("not_")]][mapping[:operator].to_s.end_with?("from") ? :from : :to] = value
159
+ when :null
160
+ filter_values[target] = "" if value
161
+ when :not_null
162
+ filter_values["#{target}!"] = "" if value
163
+ end
164
+ end
165
+
166
+ ranges.each do |(target, negated), endpoints|
167
+ filter_values["#{target}#{'!' if negated}"] = "#{endpoints[:from]}..#{endpoints[:to]}"
168
+ end
169
+
170
+ filter_values.merge(format: "json")
171
+ end
172
+
173
+ def mcp_output_columns
174
+ configured_columns = mcp_configured_output_column_names
175
+ columns = if configured_columns
176
+ configured_columns.filter_map do |column_name|
177
+ next unless active_scaffold_config.model.authorized_for?(crud_type: :read, column: column_name)
178
+
179
+ active_scaffold_config.columns[column_name]
180
+ end
181
+ else
182
+ mcp_default_output_columns
183
+ end
184
+
185
+ (mcp_primary_key_columns + columns).uniq(&:name).select do |column|
186
+ mcp_presenter.presentable_column?(column)
187
+ end
188
+ end
189
+
190
+ def mcp_configured_output_column_names
191
+ self.class.mcp_output_column_names
192
+ end
193
+
194
+ def mcp_record_hash(record, columns)
195
+ columns = columns.select do |column|
196
+ record.authorized_for?(crud_type: :read, column: column.name)
197
+ end
198
+ mcp_presenter.record(record, columns, association_counts_for(record, columns))
199
+ end
200
+
201
+ def mcp_primary_key_columns
202
+ Array(active_scaffold_config.model.primary_key).filter_map do |column_name|
203
+ next unless active_scaffold_config.model.authorized_for?(crud_type: :read, column: column_name)
204
+
205
+ active_scaffold_config.columns[column_name]
206
+ end
207
+ end
208
+
209
+ def association_counts_for(record, columns)
210
+ columns.each_with_object({}) do |column, result|
211
+ cached_counts = @counts&.dig(column.name)
212
+ next unless cached_counts
213
+
214
+ key = column.association.primary_key if count_on_association_class?(column)
215
+ result[column.name] = cached_counts[record.public_send(key || :id)] || 0
216
+ end
217
+ end
218
+
219
+ def mcp_associated_record_hash(record, polymorphic: false)
220
+ result = {id: record.id, label: record.to_label}
221
+ result[:model] = record.class.model_name.human if polymorphic
222
+ result
223
+ end
224
+
225
+ def mcp_default_output_columns
226
+ raise NotImplementedError
227
+ end
228
+
229
+ def mcp_argument_provided?(name)
230
+ @provided_mcp_arguments.include?(name.to_s)
231
+ end
232
+
233
+ # The controller implementation branches on request methods and renders
234
+ # an HTTP response. MCP operations already represent the submitted branch.
235
+ def process_action_link_action(_render_action = :action_update, crud_type_or_security_options = nil)
236
+ @action_link = active_scaffold_config.action_links[action_name]
237
+ if params[:id]
238
+ security_options = crud_type_or_security_options || {crud_type: :update, action: action_name.to_sym}
239
+ get_row(security_options)
240
+ if @record
241
+ yield @record
242
+ else
243
+ self.successful = false
244
+ flash[:error] = "Record is not available for #{action_name}"
245
+ end
246
+ else
247
+ raise ActiveScaffold::ActionNotAllowed unless action_link_authorized?(@action_link)
248
+
249
+ yield
250
+ end
251
+ end
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveScaffold
4
+ module MCP
5
+ class ToolGenerator
6
+ class << self
7
+ private
8
+
9
+ def configure_tool_class(tool_class, controller_class:, action_name:, core_config:, mcp_config:, settings:)
10
+ tool_class.active_scaffold_config = core_config
11
+ # Finder is included by the base MCP tool before a controller's
12
+ # ActiveScaffold config is known. Re-extending its class methods now
13
+ # lets Finder select the configured persistence adapter (and provide
14
+ # adapter-specific helpers such as like_pattern).
15
+ tool_class.extend ActiveScaffold::Finder::ClassMethods
16
+ tool_class.active_scaffold_controller_class = controller_class
17
+ tool_class.mcp_action_name = action_name
18
+ tool_class.mcp_output_column_names = settings[:columns]&.map(&:to_sym)
19
+ tool_class.mcp_presenter_class = build_presenter_class(settings)
20
+ tool_class.tool_name(settings[:name] || default_tool_name(action_name, mcp_config))
21
+ tool_class.title("#{action_name.to_s.humanize} #{tool_resource_label(mcp_config, settings)}")
22
+ end
23
+
24
+ def default_tool_name(action_name, mcp_config)
25
+ "#{action_name}_#{tool_resource_name(mcp_config)}"
26
+ end
27
+
28
+ def tool_resource_name(mcp_config)
29
+ mcp_config.name
30
+ end
31
+
32
+ def tool_resource_label(mcp_config, settings)
33
+ settings[:label] || mcp_config.label
34
+ end
35
+
36
+ def include_extensions(tool_class, settings)
37
+ Array(settings[:modules]).each { |extension| tool_class.include extension }
38
+ end
39
+
40
+ def build_presenter_class(settings)
41
+ presenter_class = settings[:presenter] || ActiveScaffold::MCP::Presenter
42
+ presenter_modules = Array(settings[:presenter_modules])
43
+ return presenter_class if presenter_modules.empty?
44
+
45
+ Class.new(presenter_class).tap do |configured_class|
46
+ presenter_modules.each { |extension| configured_class.include extension }
47
+ end
48
+ end
49
+
50
+ def singular_label(mcp_config)
51
+ mcp_config.label.to_s.singularize
52
+ end
53
+
54
+ def schema_options(column)
55
+ options = {type: json_type(column)}
56
+ format = json_format(column)
57
+ options[:format] = format if format
58
+ options
59
+ end
60
+
61
+ def json_type(column)
62
+ case column&.type&.to_sym
63
+ when :integer, :bigint
64
+ "integer"
65
+ when :float, :decimal
66
+ "number"
67
+ when :boolean
68
+ "boolean"
69
+ else
70
+ "string"
71
+ end
72
+ end
73
+
74
+ def json_format(column)
75
+ case column&.type&.to_sym
76
+ when :date
77
+ "date"
78
+ when :datetime, :timestamp, :timestamptz
79
+ "date-time"
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveScaffold
4
+ module MCP
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_scaffold/mcp/filtered_tools"
4
+ require "set"
5
+
6
+ module ActiveScaffold
7
+ module Actions
8
+ autoload :MCP, "active_scaffold/actions/mcp"
9
+ end
10
+
11
+ module Config
12
+ autoload :MCP, "active_scaffold/config/mcp"
13
+ end
14
+
15
+ module MCP
16
+ autoload :ActionConfig, "active_scaffold/mcp/action_config"
17
+ autoload :Flash, "active_scaffold/mcp/flash"
18
+ autoload :GeneratorFactory, "active_scaffold/mcp/generator_factory"
19
+ autoload :ListToolGenerator, "active_scaffold/mcp/list_tool_generator"
20
+ autoload :Presenter, "active_scaffold/mcp/presenter"
21
+ autoload :ProfileUser, "active_scaffold/mcp/profile_user"
22
+ autoload :SettingsValidator, "active_scaffold/mcp/settings_validator"
23
+ autoload :Tool, "active_scaffold/mcp/tool"
24
+ autoload :ToolGenerator, "active_scaffold/mcp/tool_generator"
25
+ autoload :ListTool, "active_scaffold/mcp/list_tool"
26
+ autoload :ShowTool, "active_scaffold/mcp/show_tool"
27
+ autoload :ListViewsTool, "active_scaffold/mcp/list_views_tool"
28
+ autoload :ShowToolGenerator, "active_scaffold/mcp/show_tool_generator"
29
+ autoload :VERSION, "active_scaffold/mcp/version"
30
+
31
+ class << self
32
+ def generated_controller_names
33
+ @generated_controller_names ||= Set.new
34
+ end
35
+
36
+ def generated_tool_classes
37
+ @generated_tool_classes ||= Set.new
38
+ end
39
+
40
+ def generate_tools_for(controller_class, action_config)
41
+ generator = ActiveScaffold::Config::MCP.tool_generator
42
+ return [] unless generator
43
+
44
+ generated_controller_names << controller_class.name if controller_class.name.present?
45
+ generator.generate_tools_for(controller_class, action_config).tap do |tools|
46
+ generated_tool_classes.merge(Array(tools).compact)
47
+ end
48
+ end
49
+
50
+ def reload_generated_tools!
51
+ controller_classes = generated_controller_names.filter_map(&:safe_constantize)
52
+ stale_tool_classes = generated_tool_classes.dup
53
+ ActionMCP::ToolsRegistry.items.delete_if { |_name, tool_class| stale_tool_classes.include?(tool_class) }
54
+ generated_tool_classes.clear
55
+ controller_classes.each do |controller_class|
56
+ generate_tools_for(controller_class, controller_class.active_scaffold_config.mcp)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_mcp"
4
+ require "active_scaffold"
5
+ require "active_scaffold/extensions/localize"
6
+
7
+ require "active_scaffold/mcp"
8
+ require "active_scaffold/mcp/engine"
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :action_mcp do
4
+ task load_profile_user: :environment do
5
+ next if ENV["MCP_USER"].blank?
6
+
7
+ ActionMCP::Current.user = ActiveScaffold::MCP::ProfileUser.resolve(ENV.fetch("MCP_USER"))
8
+ end
9
+
10
+ task show_profile: :load_profile_user
11
+ end
metadata ADDED
@@ -0,0 +1,104 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active_scaffold_mcp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sergio Cambra
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: actionmcp
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.200'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.200'
26
+ - !ruby/object:Gem::Dependency
27
+ name: active_scaffold
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '4.3'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '4.3'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rails
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '8.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '8.1'
54
+ description: An ActionMCP integration that generates MCP tools from ActiveScaffold
55
+ configuration.
56
+ executables: []
57
+ extensions: []
58
+ extra_rdoc_files: []
59
+ files:
60
+ - LICENSE.txt
61
+ - README.md
62
+ - lib/active_scaffold/actions/mcp.rb
63
+ - lib/active_scaffold/config/mcp.rb
64
+ - lib/active_scaffold/mcp.rb
65
+ - lib/active_scaffold/mcp/action_config.rb
66
+ - lib/active_scaffold/mcp/engine.rb
67
+ - lib/active_scaffold/mcp/filtered_tools.rb
68
+ - lib/active_scaffold/mcp/flash.rb
69
+ - lib/active_scaffold/mcp/generator_factory.rb
70
+ - lib/active_scaffold/mcp/list_tool.rb
71
+ - lib/active_scaffold/mcp/list_tool_generator.rb
72
+ - lib/active_scaffold/mcp/list_views_tool.rb
73
+ - lib/active_scaffold/mcp/presenter.rb
74
+ - lib/active_scaffold/mcp/profile_user.rb
75
+ - lib/active_scaffold/mcp/settings_validator.rb
76
+ - lib/active_scaffold/mcp/show_tool.rb
77
+ - lib/active_scaffold/mcp/show_tool_generator.rb
78
+ - lib/active_scaffold/mcp/tool.rb
79
+ - lib/active_scaffold/mcp/tool_generator.rb
80
+ - lib/active_scaffold/mcp/version.rb
81
+ - lib/active_scaffold_mcp.rb
82
+ - lib/tasks/active_scaffold_mcp_tasks.rake
83
+ homepage: https://github.com/activescaffold/active_scaffold_mcp
84
+ licenses:
85
+ - MIT
86
+ metadata: {}
87
+ rdoc_options: []
88
+ require_paths:
89
+ - lib
90
+ required_ruby_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '3.4'
95
+ required_rubygems_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ requirements: []
101
+ rubygems_version: 3.6.9
102
+ specification_version: 4
103
+ summary: Expose ActiveScaffold actions as MCP tools
104
+ test_files: []