activeadmin_mcp 0.0.3 → 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,183 @@
1
+ module ActiveadminMcp
2
+ # One ActiveAdmin action that an application has opted in to MCP, normalised
3
+ # so the rest of the engine does not care whether it came from a
4
+ # member_action, a collection_action or a batch_action.
5
+ #
6
+ # Batch actions are the special case: ActiveAdmin already knows their input
7
+ # names and widget types via `form:`, so those become param types and the
8
+ # `mcp:` declaration only layers descriptions and hints on top.
9
+ class ActionDefinition
10
+ KINDS = %i[member collection batch].freeze
11
+
12
+ # JSON Schema scalar types an application may declare.
13
+ TYPES = %i[string integer number boolean array object].freeze
14
+
15
+ # ActiveAdmin batch action form widgets -> JSON Schema types.
16
+ FORM_TYPES = {
17
+ text: :string,
18
+ string: :string,
19
+ select: :string,
20
+ datepicker: :string,
21
+ number: :number,
22
+ checkbox: :boolean,
23
+ }.freeze
24
+
25
+ attr_reader :config, :action, :kind, :errors
26
+
27
+ def self.build(config:, action:, kind:)
28
+ options = action.mcp_options
29
+ return nil unless options.is_a?(Hash)
30
+
31
+ new(config: config, action: action, kind: kind, options: options)
32
+ end
33
+
34
+ def initialize(config:, action:, kind:, options:)
35
+ @config = config
36
+ @action = action
37
+ @kind = kind
38
+ @options = options
39
+ @errors = []
40
+ validate!
41
+ end
42
+
43
+ def action_name
44
+ (@kind == :batch ? @action.sym : @action.name).to_sym
45
+ end
46
+
47
+ def resource_name
48
+ @config.resource_class.name
49
+ end
50
+
51
+ def tool_name
52
+ "#{resource_name.underscore.tr('/', '_')}_#{action_name}"
53
+ end
54
+
55
+ def description
56
+ @options[:description]
57
+ end
58
+
59
+ def permission
60
+ @options[:permission]
61
+ end
62
+
63
+ def http_verb
64
+ return :post if @kind == :batch
65
+
66
+ Array(@action.http_verb).first&.to_sym || :get
67
+ end
68
+
69
+ # Declared params, with batch actions inheriting their types from the
70
+ # ActiveAdmin `form:` hash underneath anything the declaration says.
71
+ def params
72
+ @params ||= inherited_params.merge(declared_params) do |_key, inherited, declared|
73
+ inherited.merge(declared)
74
+ end
75
+ end
76
+
77
+ def valid?
78
+ @errors.empty?
79
+ end
80
+
81
+ private
82
+
83
+ def declared_params
84
+ raw = @options[:params]
85
+ return {} unless raw.is_a?(Hash)
86
+
87
+ raw.each_with_object({}) { |(name, spec), acc| acc[name.to_sym] = spec }
88
+ end
89
+
90
+ def inherited_params
91
+ return {} unless @kind == :batch
92
+ return {} unless @action.respond_to?(:inputs)
93
+
94
+ form = @action.inputs
95
+ return {} unless form.is_a?(Hash)
96
+
97
+ form.each_with_object({}) do |(name, widget), acc|
98
+ acc[name.to_sym] = { type: FORM_TYPES.fetch(widget.to_sym, :string) }
99
+ end
100
+ end
101
+
102
+ def validate!
103
+ @errors << "#{tool_name}: mcp declaration needs a description" if description.to_s.strip.empty?
104
+ @errors << "#{tool_name}: unknown kind #{@kind}" unless KINDS.include?(@kind)
105
+
106
+ reserved = reserved_param_name
107
+ form_keys = batch_form_keys
108
+ params.each do |name, spec|
109
+ unless spec.is_a?(Hash)
110
+ @errors << "#{tool_name}: param #{name} must be a Hash"
111
+ next
112
+ end
113
+
114
+ @errors << "#{tool_name}: param #{name} is reserved" if name == reserved
115
+
116
+ # ActiveAdmin's own batch_action controller method slices the submitted
117
+ # inputs down to the keys of the batch action's `inputs` (its `form:`
118
+ # hash) before calling the block: `inputs.slice(*valid_keys)`. When
119
+ # there is no `form:` at all, `inputs` is nil, so `valid_keys` is nil,
120
+ # and `slice(*nil)` is `slice()` — which drops EVERY input, not none.
121
+ # So a form-less batch action permits nothing, and any declared param
122
+ # is a declaration error. A Proc form is evaluated by ActiveAdmin in
123
+ # controller context (MethodOrProcHelper.render_in_context), so we
124
+ # cannot know its keys here and skip the check rather than guess.
125
+ case form_keys
126
+ when :unknown_proc_form
127
+ nil
128
+ when :no_form
129
+ @errors << "#{tool_name}: param #{name} cannot be declared because this batch action " \
130
+ "has no form: hash, so ActiveAdmin drops every input before the action runs"
131
+ when nil
132
+ nil # not a batch action, or no `inputs` method at all: no rule applies
133
+ else
134
+ unless form_keys.include?(name)
135
+ @errors << "#{tool_name}: param #{name} is not among the batch action's declared " \
136
+ "form: keys, so ActiveAdmin would drop it before the action runs"
137
+ end
138
+ end
139
+
140
+ type = spec[:type]
141
+ @errors << "#{tool_name}: param #{name} has unknown type #{type}" if type && !TYPES.include?(type.to_sym)
142
+ end
143
+
144
+ @errors << "#{tool_name}: permission must be callable" if permission && !permission.respond_to?(:call)
145
+ end
146
+
147
+ # The permitted param keys for a batch action's `inputs` (its `form:`
148
+ # hash), distinguishing three outcomes the caller must treat differently:
149
+ #
150
+ # * not a batch action, or the action has no `inputs` method at all ->
151
+ # nil, the batch form: rule does not apply.
152
+ # * `inputs` is a Hash -> its keys, the permitted set ActiveAdmin will
153
+ # slice submitted params down to.
154
+ # * `inputs` is nil (no `form:` declared) -> :no_form. ActiveAdmin still
155
+ # slices, against a nil key list, which yields an EMPTY permitted set
156
+ # (`hash.slice(*nil)` is `hash.slice()` == `{}`), so every declared
157
+ # param here is a declaration error.
158
+ # * `inputs` is a Proc -> :unknown_proc_form. ActiveAdmin evaluates it in
159
+ # controller context via `render_in_context`, so we cannot know its
160
+ # keys at declaration time. We skip the check rather than guess.
161
+ def batch_form_keys
162
+ return nil unless @kind == :batch
163
+ return nil unless @action.respond_to?(:inputs)
164
+
165
+ form = @action.inputs
166
+ return form.keys.map(&:to_sym) if form.is_a?(Hash)
167
+ return :unknown_proc_form if form.is_a?(Proc)
168
+
169
+ :no_form
170
+ end
171
+
172
+ def reserved_param_name
173
+ case @kind
174
+ when :member
175
+ :id
176
+ when :batch
177
+ :ids
178
+ else
179
+ nil
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,78 @@
1
+ module ActiveadminMcp
2
+ # Validates and coerces a tool call's arguments against its ActionDefinition
3
+ # before anything reaches the controller.
4
+ #
5
+ # Undeclared arguments are dropped rather than passed through: the declaration
6
+ # is the contract, and forwarding unknown keys into a controller action would
7
+ # let a client reach parameters the application never opted in to.
8
+ class ActionParams
9
+ class CoercionError < StandardError; end
10
+
11
+ def initialize(definition)
12
+ @definition = definition
13
+ end
14
+
15
+ def call(arguments)
16
+ arguments ||= {}
17
+ result = { params: {} }
18
+
19
+ case @definition.kind
20
+ when :member
21
+ id = arguments["id"]
22
+ return { error: "id is required" } if id.nil? || id.to_s.empty?
23
+
24
+ result[:record_id] = id.to_s
25
+ when :batch
26
+ ids = Array(arguments["ids"]).reject { |id| id.to_s.empty? }
27
+ return { error: "ids is required" } if ids.empty?
28
+
29
+ result[:record_ids] = ids.map(&:to_s)
30
+ end
31
+
32
+ @definition.params.each do |name, spec|
33
+ value = arguments[name.to_s]
34
+
35
+ if value.nil? || value.to_s.empty?
36
+ return { error: "#{name} is required" } if spec[:required]
37
+
38
+ next
39
+ end
40
+
41
+ begin
42
+ coerced_value = coerce(value, spec[:type])
43
+ rescue CoercionError
44
+ return { error: "#{name} must be a #{spec[:type]}" }
45
+ end
46
+
47
+ if spec[:enum].is_a?(Array) && !spec[:enum].include?(coerced_value)
48
+ return { error: "#{name} must be one of: #{spec[:enum].join(', ')}" }
49
+ end
50
+
51
+ result[:params][name] = coerced_value
52
+ end
53
+
54
+ result
55
+ end
56
+
57
+ private
58
+
59
+ def coerce(value, type)
60
+ case type&.to_sym
61
+ when :integer
62
+ Integer(value)
63
+ when :number
64
+ Float(value)
65
+ when :boolean
66
+ case value
67
+ when true, "true", "1", 1 then true
68
+ when false, "false", "0", 0 then false
69
+ else raise CoercionError, "invalid boolean value"
70
+ end
71
+ else
72
+ value
73
+ end
74
+ rescue ArgumentError, TypeError => e
75
+ raise CoercionError, e.message
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,163 @@
1
+ module ActiveadminMcp
2
+ # Runs one opted-in ActiveAdmin action for an MCP client.
3
+ #
4
+ # Two gates gate every call, in this order, both before anything is
5
+ # dispatched:
6
+ #
7
+ # 1. ActiveAdmin's own authorization adapter - the same check `query` and
8
+ # `update` make today.
9
+ # 2. The action's optional `permission:` proc.
10
+ # 3. For batch actions only, the submitted ids are run back through the
11
+ # adapter's scope_collection, since gate 1 could only authorize the
12
+ # resource class.
13
+ #
14
+ # The proc can only ever narrow access. A record the MCP user cannot touch
15
+ # stays untouchable whether or not a proc is declared, and the controller's
16
+ # own before_action chain runs again during dispatch regardless.
17
+ class ActionRunner
18
+ def initialize(definition:, current_user:)
19
+ @definition = definition
20
+ @current_user = current_user
21
+ end
22
+
23
+ def call(arguments)
24
+ parsed = ActionParams.new(@definition).call(arguments)
25
+ return parsed if parsed[:error]
26
+
27
+ record = find_record(parsed[:record_id])
28
+ return { error: "Record not found: #{@definition.resource_name}##{parsed[:record_id]}" } if record_missing?(record, parsed)
29
+
30
+ subject = record || @definition.config.resource_class
31
+ return { error: "Not authorized to run #{@definition.tool_name}" } unless authorized?(subject)
32
+
33
+ dispatcher = ControllerDispatcher.new(config: @definition.config, current_user: @current_user)
34
+
35
+ refusal = permission_refusal(dispatcher, record, parsed)
36
+ return { error: refusal } if refusal
37
+
38
+ out_of_scope = unauthorized_batch_ids(parsed)
39
+ return { error: out_of_scope } if out_of_scope
40
+
41
+ dispatcher.call(
42
+ action: dispatch_action,
43
+ path: path_for(record, parsed),
44
+ verb: @definition.http_verb,
45
+ params: dispatch_params(parsed),
46
+ path_params: path_params(parsed)
47
+ )
48
+ end
49
+
50
+ private
51
+
52
+ def find_record(id)
53
+ return nil unless id
54
+
55
+ @definition.config.resource_class.find_by(id: id)
56
+ end
57
+
58
+ def record_missing?(record, parsed)
59
+ parsed.key?(:record_id) && record.nil?
60
+ end
61
+
62
+ def authorized?(subject)
63
+ Authorization.for(@definition.config, @current_user)
64
+ .authorized?(@definition.action_name, subject)
65
+ end
66
+
67
+ # Evaluated the way ActiveAdmin evaluates batch action `:if` procs, so
68
+ # current_admin_user, can? and the usual admin helpers are in scope.
69
+ # Returns a refusal message, or nil when the proc allows the call.
70
+ def permission_refusal(dispatcher, record, parsed)
71
+ permission = @definition.permission
72
+ return nil unless permission
73
+
74
+ controller = dispatcher.controller_with_mcp_user
75
+ outcome = ::MethodOrProcHelper.render_in_context(controller, permission, *permission_args(permission, record, parsed))
76
+
77
+ return outcome if outcome.is_a?(String)
78
+ return nil if outcome
79
+
80
+ "Not permitted to run #{@definition.tool_name}"
81
+ rescue StandardError => e
82
+ # Keep the detail server-side: the message can carry SQL, paths and other
83
+ # internals the MCP client has no business seeing.
84
+ warn("[activeadmin_mcp] permission proc for #{@definition.tool_name} raised #{e.class}: #{e.message}")
85
+ "Permission check failed for #{@definition.tool_name}"
86
+ end
87
+
88
+ # Gate 1 can only authorize the resource class for a batch action, because
89
+ # there is no single record. That leaves the submitted ids unchecked, so a
90
+ # client could name records the adapter's scope_collection excludes. Run the
91
+ # ids back through the scope and refuse the whole call if any falls outside
92
+ # it — narrowing silently would let a client believe it acted on records it
93
+ # never touched.
94
+ def unauthorized_batch_ids(parsed)
95
+ return nil unless @definition.kind == :batch
96
+
97
+ ids = Array(parsed[:record_ids])
98
+ return nil if ids.empty?
99
+
100
+ klass = @definition.config.resource_class
101
+ key = klass.primary_key
102
+ scoped = Authorization.for(@definition.config, @current_user)
103
+ .scope_collection(klass.where(key => ids), @definition.action_name)
104
+ permitted = scoped.pluck(key).map(&:to_s)
105
+ refused = ids - permitted
106
+ return nil if refused.empty?
107
+
108
+ "Not authorized to run #{@definition.tool_name} on #{@definition.resource_name} " \
109
+ "#{refused.join(', ')} (not found, or outside your permitted scope)"
110
+ rescue StandardError => e
111
+ warn("[activeadmin_mcp] scoping batch ids for #{@definition.tool_name} raised #{e.class}: #{e.message}")
112
+ "Not authorized to run #{@definition.tool_name}"
113
+ end
114
+
115
+ # render_in_context instance_execs the proc AND passes args along, so a
116
+ # zero-arity lambda would raise ArgumentError if we always handed it a
117
+ # record. Match what the proc actually accepts.
118
+ def permission_args(permission, record, parsed)
119
+ return [] if permission.respond_to?(:arity) && permission.arity.zero?
120
+
121
+ case @definition.kind
122
+ when :member then [record]
123
+ when :batch then [parsed[:record_ids]]
124
+ else []
125
+ end
126
+ end
127
+
128
+ # Batch actions enter through ActiveAdmin's own batch_action controller
129
+ # method, so its slicing of inputs to the declared form: keys applies
130
+ # exactly as it does in the admin UI.
131
+ def dispatch_action
132
+ @definition.kind == :batch ? :batch_action : @definition.action_name
133
+ end
134
+
135
+ def dispatch_params(parsed)
136
+ return parsed[:params] unless @definition.kind == :batch
137
+
138
+ {
139
+ batch_action: @definition.action_name.to_s,
140
+ collection_selection: parsed[:record_ids],
141
+ batch_action_inputs: JSON.generate(parsed[:params]),
142
+ }
143
+ end
144
+
145
+ def path_params(parsed)
146
+ parsed[:record_id] ? { id: parsed[:record_id] } : {}
147
+ end
148
+
149
+ def path_for(record, parsed)
150
+ config = @definition.config
151
+
152
+ case @definition.kind
153
+ when :member then config.route_member_action_path(@definition.action_name, record)
154
+ # RouteBuilder#batch_action_path calls `.permit!` on the params it is
155
+ # given, which a plain Hash (its own default argument) does not
156
+ # respond to. Pass ActionController::Parameters explicitly to avoid
157
+ # tripping over ActiveAdmin's own default.
158
+ when :batch then config.route_batch_action_path(ActionController::Parameters.new)
159
+ else config.route_collection_path
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,66 @@
1
+ module ActiveadminMcp
2
+ # Builds the JSON Schema an MCP client sees for one opted-in action.
3
+ #
4
+ # The binding/advisory split matters: a static `enum:` is enforced before we
5
+ # dispatch, while `suggestions:` is only ever a hint to the model. A
6
+ # suggestions proc runs application code on every tools/list call, so it is
7
+ # wrapped — a raising proc costs its suggestions, never the whole listing.
8
+ class ActionSchema
9
+ def initialize(definition)
10
+ @definition = definition
11
+ end
12
+
13
+ def to_h
14
+ properties = record_properties
15
+ required = properties.keys.map(&:to_s)
16
+
17
+ @definition.params.each do |name, spec|
18
+ properties[name] = property_for(spec)
19
+ required << name.to_s if spec[:required]
20
+ end
21
+
22
+ { type: "object", properties: properties, required: required.uniq }
23
+ end
24
+
25
+ private
26
+
27
+ def record_properties
28
+ case @definition.kind
29
+ when :member
30
+ { id: { type: "string", description: "Primary key of the record to act on" } }
31
+ when :batch
32
+ { ids: { type: "array", items: { type: "string" },
33
+ description: "Primary keys of the records to act on" } }
34
+ else
35
+ {}
36
+ end
37
+ end
38
+
39
+ def property_for(spec)
40
+ property = { type: (spec[:type] || :string).to_s }
41
+
42
+ descriptions = []
43
+ descriptions << spec[:hint] if spec[:hint]
44
+
45
+ property[:enum] = spec[:enum] if spec[:enum].is_a?(Array)
46
+
47
+ suggestions = resolve_suggestions(spec[:suggestions])
48
+ if suggestions&.any?
49
+ property[:examples] = suggestions
50
+ descriptions << "Suggested values: #{suggestions.join(', ')}"
51
+ end
52
+
53
+ property[:description] = descriptions.join(". ") unless descriptions.empty?
54
+ property
55
+ end
56
+
57
+ # Advisory only. A proc that blows up must not take the tool listing with it.
58
+ def resolve_suggestions(suggestions)
59
+ return nil unless suggestions.respond_to?(:call)
60
+
61
+ Array(suggestions.call)
62
+ rescue StandardError
63
+ nil
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,39 @@
1
+ module ActiveadminMcp
2
+ # Every patch this gem applies to ActiveAdmin lives in this one file, so the
3
+ # coupling has a single place to check when ActiveAdmin is upgraded.
4
+ #
5
+ # ActiveAdmin stores an action's option hash but exposes only the keys it
6
+ # uses itself (:method, :title, :form, :if, ...). MCP metadata is declared as
7
+ # an extra `mcp:` key on that same hash, which ActiveAdmin carries through
8
+ # untouched, so all we need is a reader to get it back out.
9
+ module ActiveAdminExt
10
+ module ActionOptions
11
+ def mcp_options
12
+ options = instance_variable_get(:@options)
13
+ options.is_a?(Hash) ? options[:mcp] : nil
14
+ end
15
+ end
16
+
17
+ # ActiveAdmin::BatchAction only exists once ActiveAdmin's before_load hooks
18
+ # have run, so this is called from ActiveAdmin.after_load rather than at
19
+ # require time.
20
+ def self.apply!
21
+ unless applicable?
22
+ # Without these readers every opted-in action silently vanishes from
23
+ # tools/list, because ActionCatalog can no longer see an mcp: option
24
+ # anywhere. Say so rather than shipping a feature that is quietly off.
25
+ warn("[activeadmin_mcp] ActiveAdmin::ControllerAction / ActiveAdmin::BatchAction not found: " \
26
+ "MCP action options cannot be read, so no opted-in actions will be exposed as tools.")
27
+ return false
28
+ end
29
+
30
+ ::ActiveAdmin::ControllerAction.include(ActionOptions)
31
+ ::ActiveAdmin::BatchAction.include(ActionOptions)
32
+ true
33
+ end
34
+
35
+ def self.applicable?
36
+ defined?(::ActiveAdmin::ControllerAction) && defined?(::ActiveAdmin::BatchAction) ? true : false
37
+ end
38
+ end
39
+ end
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module ActiveadminMcp
4
2
  # Applies a resource's ActiveAdmin authorization adapter to MCP tool calls, so
5
3
  # reads, listings and writes obey the same rules as the admin UI.
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module ActiveadminMcp
4
2
  class Configuration
5
3
  MOUNT_STRATEGIES = %i[prepend append none].freeze
@@ -0,0 +1,138 @@
1
+ module ActiveadminMcp
2
+ # Runs a real ActiveAdmin controller action out of band, so an MCP tool call
3
+ # goes through the same before_action chain, authorization and callbacks as a
4
+ # click in the admin UI.
5
+ #
6
+ # We synthesize the request rather than route one. Routing would mean issuing
7
+ # a second HTTP request against the app, which would have to authenticate as
8
+ # the MCP user by forging an admin session — a back door this gem should not
9
+ # have. Building the request by hand lets us inject the already-authenticated
10
+ # MCP user directly onto the controller instead.
11
+ class ControllerDispatcher
12
+ def initialize(config:, current_user:)
13
+ @config = config
14
+ @current_user = current_user
15
+ end
16
+
17
+ # Yields the controller once it has finished processing, so a caller that
18
+ # needs more than the redirect — the record a write built or loaded, and
19
+ # its validation errors — can read it off the controller itself. The block
20
+ # runs whether processing succeeded or raised, because a write that fails
21
+ # its validations re-renders the form, and that render is exactly the kind
22
+ # of thing a synthesized request can blow up on. Its return value is
23
+ # ignored and an exception inside it never reaches the caller.
24
+ def call(action:, path:, verb: :get, params: {}, path_params: {})
25
+ controller = controller_with_mcp_user
26
+ request = build_request(path: path, verb: verb, params: params, action: action, path_params: path_params)
27
+ response = ActionDispatch::Response.new
28
+
29
+ controller.set_request!(request)
30
+ controller.set_response!(response)
31
+
32
+ begin
33
+ controller.process(action)
34
+ capture(request, response)
35
+ rescue StandardError => e
36
+ # The exception text can carry internals — SQL fragments, table names,
37
+ # file paths. It belongs in the application's log, not in a tool result
38
+ # that goes to an MCP client.
39
+ warn("[activeadmin_mcp] #{@config.resource_class.name}##{action} raised #{e.class}: #{e.message}")
40
+ { error: "#{@config.resource_class.name}##{action} failed" }
41
+ ensure
42
+ inspect_controller(controller, action) { |processed| yield processed } if block_given?
43
+ end
44
+ end
45
+
46
+ # A controller instance for this resource with the MCP user injected, ready
47
+ # either to process a request or to serve as the evaluation context for an
48
+ # action's `permission:` proc. Public because listing-time permission checks
49
+ # need exactly the same context a dispatched call gets.
50
+ def controller_with_mcp_user
51
+ user = @current_user
52
+ controller = @config.controller.new
53
+
54
+ current_user_methods.each do |method_name|
55
+ controller.define_singleton_method(method_name) { user }
56
+ end
57
+
58
+ # The namespace's authentication_method (typically Devise's
59
+ # authenticate_admin_user!) would redirect us to a login page. The MCP
60
+ # request has already authenticated by bearer token, so it is a no-op
61
+ # here — this does NOT skip authorization, which still runs in full.
62
+ auth_method = @config.namespace.authentication_method
63
+ controller.define_singleton_method(auth_method) { true } if auth_method
64
+
65
+ # The synthesized request carries no session-bound CSRF token, so
66
+ # Rails' own forgery protection would refuse every non-GET action
67
+ # (member actions declared `method: :post`, and every batch action,
68
+ # which always dispatches as one). The MCP request has already
69
+ # authenticated by bearer token; this does NOT skip authorization,
70
+ # which still runs in full.
71
+ controller.define_singleton_method(:verified_request?) { true }
72
+
73
+ controller
74
+ end
75
+
76
+ private
77
+
78
+ # An application can point ActiveadminMcp at one current-user method and the
79
+ # ActiveAdmin namespace at another. Stub both, so an action body calling
80
+ # either gets the MCP user rather than nil. uniq keeps us from defining the
81
+ # same singleton method twice when they agree.
82
+ def current_user_methods
83
+ namespace_method = @config.namespace.current_user_method if @config.namespace.respond_to?(:current_user_method)
84
+
85
+ [
86
+ ActiveadminMcp.config.current_user_method,
87
+ namespace_method,
88
+ :current_active_admin_user,
89
+ ].select { |name| name.respond_to?(:to_sym) }.map(&:to_sym).uniq
90
+ end
91
+
92
+ def inspect_controller(controller, action)
93
+ yield controller
94
+ rescue StandardError => e
95
+ warn("[activeadmin_mcp] inspecting #{@config.resource_class.name}##{action} raised #{e.class}: #{e.message}")
96
+ end
97
+
98
+ def build_request(path:, verb:, params:, action:, path_params:)
99
+ env = Rack::MockRequest.env_for(
100
+ path,
101
+ method: verb.to_s.upcase,
102
+ params: params.transform_keys(&:to_s)
103
+ )
104
+ # Flash needs somewhere to live; without a session the action raises.
105
+ env["rack.session"] = {}
106
+ env["action_dispatch.request.path_parameters"] =
107
+ { controller: controller_path, action: action.to_s }.merge(path_params)
108
+
109
+ ActionDispatch::Request.new(env)
110
+ end
111
+
112
+ def controller_path
113
+ @config.controller.name.underscore.sub(/_controller\z/, "")
114
+ end
115
+
116
+ def capture(request, response)
117
+ result = { status: response.status }
118
+
119
+ if response.redirect?
120
+ result[:redirect_to] = response.location
121
+ else
122
+ # Deliberately not the body: admin HTML is large and almost entirely
123
+ # chrome, and would swamp the client's context for no benefit.
124
+ result[:rendered] = true
125
+ end
126
+
127
+ flash = extract_flash(request)
128
+ result[:flash] = flash if flash&.any?
129
+ result
130
+ end
131
+
132
+ def extract_flash(request)
133
+ request.flash.to_hash
134
+ rescue StandardError
135
+ nil
136
+ end
137
+ end
138
+ end