activeadmin_mcp 0.0.4 → 0.2.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
+ 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
+ # MCP tool names are referenced by clients as identifiers, so keep them to
13
+ # what every client can quote without escaping.
14
+ TOOL_NAME = /\A[a-z0-9][a-z0-9_-]*\z/
15
+
16
+ # JSON Schema scalar types an application may declare.
17
+ TYPES = %i[string integer number boolean array object].freeze
18
+
19
+ # ActiveAdmin batch action form widgets -> JSON Schema types.
20
+ FORM_TYPES = {
21
+ text: :string,
22
+ string: :string,
23
+ select: :string,
24
+ datepicker: :string,
25
+ number: :number,
26
+ checkbox: :boolean,
27
+ }.freeze
28
+
29
+ attr_reader :config, :action, :kind, :errors
30
+
31
+ def self.build(config:, action:, kind:, current_user: nil)
32
+ options = action.mcp_options
33
+ return nil unless options.is_a?(Hash)
34
+
35
+ new(config: config, action: action, kind: kind, options: options, current_user: current_user)
36
+ end
37
+
38
+ def initialize(config:, action:, kind:, options:, current_user: nil)
39
+ @config = config
40
+ @action = action
41
+ @kind = kind
42
+ @options = options
43
+ @current_user = current_user
44
+ @errors = []
45
+ validate!
46
+ end
47
+
48
+ def action_name
49
+ (@kind == :batch ? @action.sym : @action.name).to_sym
50
+ end
51
+
52
+ def resource_name
53
+ @config.resource_class.name
54
+ end
55
+
56
+ # A declaration may choose its own name: an action shared by a concern can
57
+ # need a different one on each resource, and an action exposed once per
58
+ # verb needs a distinct name per tool.
59
+ def tool_name
60
+ declared = @options[:tool_name]
61
+ return declared.to_s if declared
62
+
63
+ derived_tool_name
64
+ end
65
+
66
+ # Squeezed down to the characters a tool name may carry, because an action
67
+ # name is not always tame: ActiveAdmin derives a batch action's symbol from
68
+ # a String title and can leave punctuation in it. A resource may still
69
+ # choose its own name with tool_name:, which is validated rather than
70
+ # squeezed, since a name someone typed deliberately should not be silently
71
+ # rewritten.
72
+ def derived_tool_name
73
+ "#{resource_name.underscore.tr('/', '_')}_#{action_name}"
74
+ .downcase
75
+ .gsub(/[^a-z0-9_-]+/, "_")
76
+ .squeeze("_")
77
+ .delete_suffix("_")
78
+ end
79
+
80
+ def description
81
+ @options[:description]
82
+ end
83
+
84
+ def permission
85
+ @options[:permission]
86
+ end
87
+
88
+ # ActiveAdmin's own `:if` proc on a batch action, which decides whether the
89
+ # admin UI offers it at all. Returned rather than evaluated: like a
90
+ # `permission:` proc it belongs in controller context, which only the
91
+ # caller can build.
92
+ def display_if
93
+ return nil unless @kind == :batch
94
+ return nil unless @action.respond_to?(:display_if_block)
95
+
96
+ @action.display_if_block
97
+ end
98
+
99
+ # ActiveAdmin always posts a batch action, whatever a declaration says.
100
+ # Otherwise a declaration may pick among the verbs the action answers to —
101
+ # `method: [:post, :delete]` is one action with two meanings, and without
102
+ # this only the first would ever be reachable.
103
+ def http_verb
104
+ return :post if @kind == :batch
105
+
106
+ declared = @options[:http_verb]&.to_sym
107
+ return declared if declared
108
+
109
+ action_verbs.first || :get
110
+ end
111
+
112
+ def action_verbs
113
+ Array(@action.http_verb).compact.map(&:to_sym)
114
+ end
115
+
116
+ # Declared params, with batch actions inheriting their types from the
117
+ # ActiveAdmin `form:` hash underneath anything the declaration says.
118
+ def params
119
+ @params ||= inherited_params.merge(declared_params) do |_key, inherited, declared|
120
+ inherited.merge(declared)
121
+ end
122
+ end
123
+
124
+ def valid?
125
+ @errors.empty?
126
+ end
127
+
128
+ private
129
+
130
+ def declared_params
131
+ raw = @options[:params]
132
+ return {} unless raw.is_a?(Hash)
133
+
134
+ raw.each_with_object({}) { |(name, spec), acc| acc[name.to_sym] = spec }
135
+ end
136
+
137
+ def inherited_params
138
+ return {} unless @kind == :batch
139
+
140
+ resolved_form.each_with_object({}) do |(name, widget), acc|
141
+ acc[name.to_sym] = { type: form_type(widget) }
142
+ end
143
+ end
144
+
145
+ # A form: entry is usually a widget name, but ActiveAdmin also accepts an
146
+ # array of options, which it renders as a select. There is no type to read
147
+ # off that, and its values are not treated as a binding enum: they were
148
+ # resolved once, at listing time, and a declaration wanting to offer them
149
+ # should say so with suggestions:, which is advisory by design.
150
+ def form_type(widget)
151
+ return :string unless widget.respond_to?(:to_sym)
152
+
153
+ FORM_TYPES.fetch(widget.to_sym, :string)
154
+ end
155
+
156
+ # ActiveAdmin lets `form:` be a proc and evaluates it in controller context
157
+ # at render time, which is the only way a concern shared across resources
158
+ # can vary its options. Evaluated here the same way, so a proc form still
159
+ # contributes its param types.
160
+ #
161
+ # Deliberately lazy: this runs application code, so it must not happen
162
+ # while the catalog is merely being built, before the caller has checked
163
+ # the user is authorized for the action at all. Same posture as
164
+ # `suggestions:`.
165
+ def resolved_form
166
+ return @resolved_form if defined?(@resolved_form)
167
+
168
+ @resolved_form = resolve_form || {}
169
+ end
170
+
171
+ def resolve_form
172
+ return nil unless @action.respond_to?(:inputs)
173
+
174
+ form = @action.inputs
175
+ return form if form.is_a?(Hash)
176
+ return nil unless form.is_a?(Proc)
177
+
178
+ evaluate_form(form)
179
+ end
180
+
181
+ def evaluate_form(form)
182
+ controller = ControllerDispatcher.new(config: @config, current_user: @current_user)
183
+ .controller_with_mcp_user
184
+ evaluated = ::MethodOrProcHelper.render_in_context(controller, form)
185
+ evaluated.is_a?(Hash) ? evaluated : nil
186
+ rescue StandardError => e
187
+ # The tool keeps whatever the declaration said; it just inherits nothing.
188
+ warn("[activeadmin_mcp] evaluating the #{tool_name} form: proc raised #{e.class}: #{e.message}")
189
+ nil
190
+ end
191
+
192
+ def validate!
193
+ @errors << "#{tool_name}: mcp declaration needs a description" if description.to_s.strip.empty?
194
+ @errors << "#{tool_name}: unknown kind #{@kind}" unless KINDS.include?(@kind)
195
+
196
+ reserved = reserved_param_name
197
+ form_keys = batch_form_keys
198
+ # Declared params only. Inherited ones come from ActiveAdmin's own form:
199
+ # hash and are always well formed, and reading them would mean evaluating
200
+ # a proc form here — the one place it must not happen.
201
+ declared_params.each do |name, spec|
202
+ unless spec.is_a?(Hash)
203
+ @errors << "#{tool_name}: param #{name} must be a Hash"
204
+ next
205
+ end
206
+
207
+ @errors << "#{tool_name}: param #{name} is reserved" if name == reserved
208
+
209
+ # ActiveAdmin's own batch_action controller method slices the submitted
210
+ # inputs down to the keys of the batch action's `inputs` (its `form:`
211
+ # hash) before calling the block: `inputs.slice(*valid_keys)`. When
212
+ # there is no `form:` at all, `inputs` is nil, so `valid_keys` is nil,
213
+ # and `slice(*nil)` is `slice()` — which drops EVERY input, not none.
214
+ # So a form-less batch action permits nothing, and any declared param
215
+ # is a declaration error. A Proc form is evaluated by ActiveAdmin in
216
+ # controller context (MethodOrProcHelper.render_in_context), so we
217
+ # cannot know its keys here and skip the check rather than guess.
218
+ case form_keys
219
+ when :unknown_proc_form
220
+ nil
221
+ when :no_form
222
+ @errors << "#{tool_name}: param #{name} cannot be declared because this batch action " \
223
+ "has no form: hash, so ActiveAdmin drops every input before the action runs"
224
+ when nil
225
+ nil # not a batch action, or no `inputs` method at all: no rule applies
226
+ else
227
+ unless form_keys.include?(name)
228
+ @errors << "#{tool_name}: param #{name} is not among the batch action's declared " \
229
+ "form: keys, so ActiveAdmin would drop it before the action runs"
230
+ end
231
+ end
232
+
233
+ type = spec[:type]
234
+ @errors << "#{tool_name}: param #{name} has unknown type #{type}" if type && !TYPES.include?(type.to_sym)
235
+ end
236
+
237
+ @errors << "#{tool_name}: permission must be callable" if permission && !permission.respond_to?(:call)
238
+
239
+ validate_tool_name!
240
+ validate_http_verb!
241
+ end
242
+
243
+ def validate_tool_name!
244
+ return if tool_name.match?(TOOL_NAME)
245
+
246
+ @errors << "#{tool_name}: tool_name must match #{TOOL_NAME.source}"
247
+ end
248
+
249
+ # Dispatching a verb the action never declared would reach nothing, or
250
+ # worse, the wrong branch of the action's own body.
251
+ def validate_http_verb!
252
+ declared = @options[:http_verb]&.to_sym
253
+ return if declared.nil? || @kind == :batch
254
+
255
+ verbs = action_verbs
256
+ return if verbs.empty? || verbs.include?(declared)
257
+
258
+ @errors << "#{tool_name}: http_verb #{declared} is not one the action answers to (#{verbs.join(', ')})"
259
+ end
260
+
261
+ # The permitted param keys for a batch action's `inputs` (its `form:`
262
+ # hash), distinguishing three outcomes the caller must treat differently:
263
+ #
264
+ # * not a batch action, or the action has no `inputs` method at all ->
265
+ # nil, the batch form: rule does not apply.
266
+ # * `inputs` is a Hash -> its keys, the permitted set ActiveAdmin will
267
+ # slice submitted params down to.
268
+ # * `inputs` is nil (no `form:` declared) -> :no_form. ActiveAdmin still
269
+ # slices, against a nil key list, which yields an EMPTY permitted set
270
+ # (`hash.slice(*nil)` is `hash.slice()` == `{}`), so every declared
271
+ # param here is a declaration error.
272
+ # * `inputs` is a Proc -> :unknown_proc_form. ActiveAdmin evaluates it in
273
+ # controller context via `render_in_context`, so we cannot know its
274
+ # keys at declaration time. We skip the check rather than guess.
275
+ def batch_form_keys
276
+ return nil unless @kind == :batch
277
+ return nil unless @action.respond_to?(:inputs)
278
+
279
+ form = @action.inputs
280
+ return form.keys.map(&:to_sym) if form.is_a?(Hash)
281
+ return :unknown_proc_form if form.is_a?(Proc)
282
+
283
+ :no_form
284
+ end
285
+
286
+ def reserved_param_name
287
+ case @kind
288
+ when :member
289
+ :id
290
+ when :batch
291
+ :ids
292
+ else
293
+ nil
294
+ end
295
+ end
296
+ end
297
+ 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,184 @@
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
+ unavailable = display_refusal(dispatcher)
36
+ return { error: unavailable } if unavailable
37
+
38
+ refusal = permission_refusal(dispatcher, record, parsed)
39
+ return { error: refusal } if refusal
40
+
41
+ out_of_scope = unauthorized_batch_ids(parsed)
42
+ return { error: out_of_scope } if out_of_scope
43
+
44
+ dispatcher.call(
45
+ action: dispatch_action,
46
+ path: path_for(record, parsed),
47
+ verb: @definition.http_verb,
48
+ params: dispatch_params(parsed),
49
+ path_params: path_params(parsed)
50
+ )
51
+ end
52
+
53
+ private
54
+
55
+ def find_record(id)
56
+ return nil unless id
57
+
58
+ @definition.config.resource_class.find_by(id: id)
59
+ end
60
+
61
+ def record_missing?(record, parsed)
62
+ parsed.key?(:record_id) && record.nil?
63
+ end
64
+
65
+ def authorized?(subject)
66
+ Authorization.for(@definition.config, @current_user)
67
+ .authorized?(@definition.action_name, subject)
68
+ end
69
+
70
+ # ActiveAdmin's `:if` proc decides whether the admin UI offers a batch
71
+ # action at all, but ActiveAdmin only consults it when rendering — a
72
+ # dispatched request reaches the action regardless. Consulting it here too
73
+ # keeps MCP from becoming the way round a gate the admin enforces by not
74
+ # offering the button.
75
+ def display_refusal(dispatcher)
76
+ block = @definition.display_if
77
+ return nil unless block
78
+
79
+ controller = dispatcher.controller_with_mcp_user
80
+ return nil if ::MethodOrProcHelper.render_in_context(controller, block)
81
+
82
+ "#{@definition.tool_name} is not available to you"
83
+ rescue StandardError => e
84
+ warn("[activeadmin_mcp] if: proc for #{@definition.tool_name} raised #{e.class}: #{e.message}")
85
+ "#{@definition.tool_name} is not available to you"
86
+ end
87
+
88
+ # Evaluated the way ActiveAdmin evaluates batch action `:if` procs, so
89
+ # current_admin_user, can? and the usual admin helpers are in scope.
90
+ # Returns a refusal message, or nil when the proc allows the call.
91
+ def permission_refusal(dispatcher, record, parsed)
92
+ permission = @definition.permission
93
+ return nil unless permission
94
+
95
+ controller = dispatcher.controller_with_mcp_user
96
+ outcome = ::MethodOrProcHelper.render_in_context(controller, permission, *permission_args(permission, record, parsed))
97
+
98
+ return outcome if outcome.is_a?(String)
99
+ return nil if outcome
100
+
101
+ "Not permitted to run #{@definition.tool_name}"
102
+ rescue StandardError => e
103
+ # Keep the detail server-side: the message can carry SQL, paths and other
104
+ # internals the MCP client has no business seeing.
105
+ warn("[activeadmin_mcp] permission proc for #{@definition.tool_name} raised #{e.class}: #{e.message}")
106
+ "Permission check failed for #{@definition.tool_name}"
107
+ end
108
+
109
+ # Gate 1 can only authorize the resource class for a batch action, because
110
+ # there is no single record. That leaves the submitted ids unchecked, so a
111
+ # client could name records the adapter's scope_collection excludes. Run the
112
+ # ids back through the scope and refuse the whole call if any falls outside
113
+ # it — narrowing silently would let a client believe it acted on records it
114
+ # never touched.
115
+ def unauthorized_batch_ids(parsed)
116
+ return nil unless @definition.kind == :batch
117
+
118
+ ids = Array(parsed[:record_ids])
119
+ return nil if ids.empty?
120
+
121
+ klass = @definition.config.resource_class
122
+ key = klass.primary_key
123
+ scoped = Authorization.for(@definition.config, @current_user)
124
+ .scope_collection(klass.where(key => ids), @definition.action_name)
125
+ permitted = scoped.pluck(key).map(&:to_s)
126
+ refused = ids - permitted
127
+ return nil if refused.empty?
128
+
129
+ "Not authorized to run #{@definition.tool_name} on #{@definition.resource_name} " \
130
+ "#{refused.join(', ')} (not found, or outside your permitted scope)"
131
+ rescue StandardError => e
132
+ warn("[activeadmin_mcp] scoping batch ids for #{@definition.tool_name} raised #{e.class}: #{e.message}")
133
+ "Not authorized to run #{@definition.tool_name}"
134
+ end
135
+
136
+ # render_in_context instance_execs the proc AND passes args along, so a
137
+ # zero-arity lambda would raise ArgumentError if we always handed it a
138
+ # record. Match what the proc actually accepts.
139
+ def permission_args(permission, record, parsed)
140
+ return [] if permission.respond_to?(:arity) && permission.arity.zero?
141
+
142
+ case @definition.kind
143
+ when :member then [record]
144
+ when :batch then [parsed[:record_ids]]
145
+ else []
146
+ end
147
+ end
148
+
149
+ # Batch actions enter through ActiveAdmin's own batch_action controller
150
+ # method, so its slicing of inputs to the declared form: keys applies
151
+ # exactly as it does in the admin UI.
152
+ def dispatch_action
153
+ @definition.kind == :batch ? :batch_action : @definition.action_name
154
+ end
155
+
156
+ def dispatch_params(parsed)
157
+ return parsed[:params] unless @definition.kind == :batch
158
+
159
+ {
160
+ batch_action: @definition.action_name.to_s,
161
+ collection_selection: parsed[:record_ids],
162
+ batch_action_inputs: JSON.generate(parsed[:params]),
163
+ }
164
+ end
165
+
166
+ def path_params(parsed)
167
+ parsed[:record_id] ? { id: parsed[:record_id] } : {}
168
+ end
169
+
170
+ def path_for(record, parsed)
171
+ config = @definition.config
172
+
173
+ case @definition.kind
174
+ when :member then config.route_member_action_path(@definition.action_name, record)
175
+ # RouteBuilder#batch_action_path calls `.permit!` on the params it is
176
+ # given, which a plain Hash (its own default argument) does not
177
+ # respond to. Pass ActionController::Parameters explicitly to avoid
178
+ # tripping over ActiveAdmin's own default.
179
+ when :batch then config.route_batch_action_path(ActionController::Parameters.new)
180
+ else config.route_collection_path
181
+ end
182
+ end
183
+ end
184
+ 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