activeadmin_mcp 0.1.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aac4aa9e2af5d377f711090b2e8adad79915844e26842733f5c8af6b02f37d7b
4
- data.tar.gz: c418625d7b5bff3c9e555d0d248e098c08a8ac1a224644e900fa14fa3a95dfa7
3
+ metadata.gz: 3c5af58e285dcddc7105564342dc34b55531810db4ed351b10d45a1e018ab9f4
4
+ data.tar.gz: 5b03712e5ea784179d552b670875183324135a9835d4bceead9301433f82e8a5
5
5
  SHA512:
6
- metadata.gz: aea999468c061e6d532b0edb1e3241393a78dc34c1f94c0b07c2108431ab4370b4c89d45940a6691dce0ce5bfbc6903f92bc087457fc4d557fd70a97dacc9278
7
- data.tar.gz: 588af72e3801e3c2c728d0407e217c7ebf54277d07a8dbdbd518fdbfe7e9cb335601cc3afe90cbcbbe76af40ef55b6bddde982ce11632107aca957875e34e464
6
+ metadata.gz: cc3367de9915d2c12ac449a410328a553941341f76bfd2cf8b331c8f22a1cfb0a070f56a6990772ded10893ee1259af4d36f28defe6fc73cb52013a582f0c42e
7
+ data.tar.gz: 78b2f980db593f334e4349ede90bab1d264ab03796ebcbebc4673b79f30670eb9902e748c24b687f0d04250b64d36fbd2455644e7bce9dfddaaf33e9ad6d87b6
data/README.md CHANGED
@@ -123,7 +123,10 @@ action, so a write from MCP is the same write the admin UI makes:
123
123
  so a `slug` sent to a `Post` permitting only `title` and `body` never
124
124
  reaches the record. A resource that declares no `permit_params` at all (like
125
125
  `Tag`) is refused outright, with a message saying so — ActiveAdmin cannot
126
- write such a resource through its own forms either.
126
+ write such a resource through its own forms either. A block-form
127
+ `permit_params` is evaluated in controller context as the authenticated MCP
128
+ user, so a block varying the writable set by user gets the same answer it
129
+ would give that user in admin.
127
130
  - **Your callbacks run** — ActiveAdmin's `before_build`, `before_create`,
128
131
  `before_save`, `after_update` and friends all fire, because the controller
129
132
  action is what fires them. A `before_create` that fills in a `slug` the form
@@ -154,8 +157,14 @@ at render time, so there is nothing to read. The response's `source` says which
154
157
  of the two you are looking at.
155
158
 
156
159
  Either way every field is annotated from the model with the column type it is
157
- stored in and whether the model validates its presence. `has_many` blocks are
158
- reported under `nested` rather than flattened in with the record's own fields.
160
+ stored in and whether the model validates its presence. An associated record's
161
+ fields — declared with `has_many`, or with `inputs for: :author` — are reported
162
+ under `nested` rather than flattened in with the record's own, because a write
163
+ against the parent would drop them.
164
+
165
+ A form block that declares no inputs of its own is described from
166
+ `permit_params` too. A bare `f.inputs` is legal, and Formtastic only expands it
167
+ against the model at render time, so there is nothing in the block to read.
159
168
 
160
169
  `action:` selects the gate, not the shape — ActiveAdmin uses one form block for
161
170
  both. `"new"` (the default) requires the resource to register `create` and pass
@@ -174,6 +183,13 @@ Two limits worth knowing:
174
183
  - A field a form block declares but `permit_params` omits is described and then
175
184
  silently dropped on write. This cannot arise on the `permit_params` fallback
176
185
  path.
186
+ - On the `permit_params` fallback path, only fields backed by a database column
187
+ are reported. ActiveAdmin keeps no list of the params it permits — only a
188
+ method that filters against them — so the names are recovered by offering it
189
+ every column the model has and seeing which survive. Permitted params that
190
+ are not columns, such as `tag_ids` or a nested `*_attributes` key, are
191
+ therefore missing from the description even though `create` and `update`
192
+ will accept them.
177
193
 
178
194
  ### Running member, collection and batch actions
179
195
 
@@ -297,6 +313,83 @@ Redirect-style (submit-side) actions are the supported case; a `GET` action that
297
313
  renders a full admin view is best-effort and may fail for want of a view
298
314
  context.
299
315
 
316
+ ### Actions declared somewhere you can't add `mcp:`
317
+
318
+ An action declared by a shared concern, or by another gem, has no declaration
319
+ you can hang an `mcp:` key on — and if it did, every resource including it
320
+ would get the same description, params and `permission:` proc. Annotate it by
321
+ name from the registration instead, with `mcp_action`:
322
+
323
+ ```ruby
324
+ module Flaggable
325
+ def self.included(dsl)
326
+ dsl.send(:member_action, :flag, method: [:post, :delete]) { ... }
327
+ dsl.send(:batch_action, :flag, form: proc { { reason: :text } }) { |ids, inputs| ... }
328
+ end
329
+ end
330
+
331
+ ActiveAdmin.register Volunteer do
332
+ include Flaggable
333
+
334
+ mcp_action :flag, kind: :batch, tool_name: "volunteer_bulk_flag",
335
+ description: "Flag the selected volunteers",
336
+ params: { reason: { type: :string, required: true } }
337
+
338
+ mcp_action :flag, kind: :member, tool_name: "volunteer_flag",
339
+ description: "Flag a volunteer",
340
+ params: { reason: { type: :string, required: true } }
341
+
342
+ mcp_action :flag, kind: :member, http_verb: :delete, tool_name: "volunteer_unflag",
343
+ description: "Remove a volunteer's flag"
344
+ end
345
+ ```
346
+
347
+ `mcp_action` takes everything `mcp:` takes, plus:
348
+
349
+ | Option | Meaning |
350
+ |--------|---------|
351
+ | `kind:` | `:member`, `:collection` or `:batch`. Optional; needed only when one name belongs to more than one action, which is refused rather than guessed. |
352
+ | `tool_name:` | The MCP tool name, in place of the derived `<resource>_<action>`. |
353
+ | `http_verb:` | Which verb to dispatch, for an action declared with several (`method: [:post, :delete]`). A verb the action does not answer to is a declaration error. |
354
+
355
+ It **annotates**; it never declares. Naming an action the resource does not
356
+ have warns and skips. It is resolved when the tool list is built, not when it
357
+ is called, so it may appear above or below the `include`.
358
+
359
+ An annotation **replaces** an inline `mcp:` declaration rather than merging
360
+ into it, so a shared generic declaration and a per-resource one cannot
361
+ half-combine into something neither author wrote.
362
+
363
+ An action may carry more than one annotation, each producing its own tool —
364
+ which is how an action answering to two verbs, one undoing the other, becomes
365
+ two tools.
366
+
367
+ **Opting in is still per resource.** Two resources including the same concern
368
+ share the actions, not the exposure: whichever does not annotate exposes
369
+ nothing.
370
+
371
+ **Names must not collide.** A tool name carries no kind, so a `member_action`
372
+ and a `batch_action` of the same name derive the same one. Rather than let one
373
+ silently shadow the other, both are hidden until a `tool_name:` tells them
374
+ apart.
375
+
376
+ **Batch actions declared with a String title** — the ones applications generate
377
+ in loops from data — may be annotated by that title, rather than by the symbol
378
+ ActiveAdmin derives from it by titleizing and underscoring, which can carry
379
+ punctuation. The derived tool name has that punctuation squeezed out.
380
+
381
+ **ActiveAdmin's `:if` proc is honoured.** A batch action the admin UI hides
382
+ because its `:if` refuses is neither listed nor runnable over MCP. ActiveAdmin
383
+ itself consults `:if` only when rendering, so this is stricter than ActiveAdmin
384
+ is — deliberately: MCP should not be the way round a gate the admin enforces by
385
+ not offering the button. A proc that raises, typically because it reads request
386
+ state a tool listing cannot supply, hides the tool and says so in the log.
387
+
388
+ **A proc `form:` is evaluated** in controller context, the way ActiveAdmin
389
+ evaluates it, so a batch action whose form varies by resource still contributes
390
+ its param types. Like `suggestions:`, this runs application code, and is never
391
+ evaluated for a user the resource's authorization adapter refuses.
392
+
300
393
  ## Connecting a client
301
394
 
302
395
  `activeadmin_mcp` has been tested with **Claude Code** (Anthropic) over the
@@ -9,20 +9,47 @@ module ActiveadminMcp
9
9
  RESERVED = %w[list_resources query update create describe_form].freeze
10
10
 
11
11
  class << self
12
- def all
13
- ResourceRegistry.resources.flat_map { |entry| definitions_for(entry[:config]) }
12
+ # The authenticated user is carried through so a batch action's `form:`
13
+ # proc can be evaluated in controller context, the way ActiveAdmin
14
+ # evaluates it. Nothing here evaluates it — see ActionDefinition#params,
15
+ # which does, lazily, once the caller has authorized the action.
16
+ def all(current_user: nil)
17
+ without_colliding_names(
18
+ ResourceRegistry.resources.flat_map do |entry|
19
+ definitions_for(entry[:config], current_user)
20
+ end
21
+ )
14
22
  end
15
23
 
16
- def find(tool_name)
17
- all.find { |definition| definition.tool_name == tool_name }
24
+ def find(tool_name, current_user: nil)
25
+ all(current_user: current_user).find { |definition| definition.tool_name == tool_name }
18
26
  end
19
27
 
20
28
  private
21
29
 
22
- def definitions_for(config)
23
- candidates(config).filter_map do |action, kind|
24
- definition = ActionDefinition.build(config: config, action: action, kind: kind)
25
- next unless definition
30
+ # A tool name carries no kind, so a member and a batch action of the same
31
+ # name derive the same one — which ActiveAdmin allows, and a shared
32
+ # concern declaring both is how it happens in practice. Advertising a
33
+ # duplicate would leave whichever the catalog found second permanently
34
+ # unreachable, since find returns the first match. Refuse both instead,
35
+ # and say which name, so the declaration can choose a tool_name.
36
+ def without_colliding_names(definitions)
37
+ definitions.group_by(&:tool_name).flat_map do |tool_name, sharing|
38
+ next sharing if sharing.one?
39
+
40
+ warn_and_skip("#{sharing.length} actions would both be called #{tool_name}; " \
41
+ "give all but one an explicit tool_name:")
42
+ []
43
+ end
44
+ end
45
+
46
+ def definitions_for(config, current_user = nil)
47
+ annotated, inline = partition_declarations(config)
48
+
49
+ (annotated + inline).filter_map do |action, kind, options|
50
+ definition = ActionDefinition.new(
51
+ config: config, action: action, kind: kind, options: options, current_user: current_user
52
+ )
26
53
 
27
54
  next warn_and_skip(definition.errors.join("; ")) unless definition.valid?
28
55
  next warn_and_skip("#{definition.tool_name} collides with a built-in tool") if reserved?(definition)
@@ -31,6 +58,80 @@ module ActiveadminMcp
31
58
  end
32
59
  end
33
60
 
61
+ # Resolves each mcp_action annotation to the action it names, and leaves
62
+ # every unannotated action to its own inline mcp: declaration. An
63
+ # annotation replaces an inline declaration wholesale rather than merging
64
+ # into it: one declaration wins, and it is visible which.
65
+ def partition_declarations(config)
66
+ actions = candidates(config)
67
+ annotated = []
68
+ claimed = []
69
+
70
+ safe_annotations(config).each do |annotation|
71
+ matches = matching_actions(actions, annotation)
72
+
73
+ next warn_and_skip(annotation_missing(config, annotation)) if matches.empty?
74
+ next warn_and_skip(annotation_ambiguous(config, annotation)) unless matches.one?
75
+
76
+ action, kind = matches.first
77
+ claimed << action
78
+ annotated << [action, kind, annotation[:options]]
79
+ end
80
+
81
+ inline = actions.filter_map do |action, kind|
82
+ # By identity, not equality: ActiveAdmin::ControllerAction is
83
+ # Comparable through a `priority` its instances do not all have, so
84
+ # asking an Array whether it includes one can raise.
85
+ next if claimed.any? { |claimed_action| claimed_action.equal?(action) }
86
+
87
+ options = action.mcp_options
88
+ [action, kind, options] if options.is_a?(Hash)
89
+ end
90
+
91
+ [annotated, inline]
92
+ end
93
+
94
+ def matching_actions(actions, annotation)
95
+ wanted = annotation[:action_name].to_s
96
+
97
+ actions.select do |action, kind|
98
+ names_of(action, kind).include?(wanted) &&
99
+ (annotation[:kind].nil? || annotation[:kind] == kind)
100
+ end
101
+ end
102
+
103
+ # A batch action may be declared with a String title, from which
104
+ # ActiveAdmin derives the symbol by titleizing, stripping spaces and
105
+ # underscoring — which can leave punctuation in it. Applications generate
106
+ # those in loops from data, so an annotation may name either the title it
107
+ # wrote or the symbol ActiveAdmin made of it.
108
+ def names_of(action, kind)
109
+ names = [declared_name(action, kind).to_s]
110
+ names << action.title.to_s if kind == :batch && action.respond_to?(:title)
111
+ names
112
+ end
113
+
114
+ def declared_name(action, kind)
115
+ (kind == :batch ? action.sym : action.name).to_sym
116
+ end
117
+
118
+ def annotation_missing(config, annotation)
119
+ "#{resource_name(config)} annotates #{annotation[:action_name]}, which it does not declare"
120
+ end
121
+
122
+ def annotation_ambiguous(config, annotation)
123
+ "#{resource_name(config)} annotates #{annotation[:action_name]}, which names more than one " \
124
+ "action; say which with kind:"
125
+ end
126
+
127
+ def resource_name(config)
128
+ config.resource_class.name
129
+ end
130
+
131
+ def safe_annotations(config)
132
+ config.respond_to?(:mcp_annotations) ? Array(config.mcp_annotations) : []
133
+ end
134
+
34
135
  def candidates(config)
35
136
  pairs = []
36
137
  pairs.concat(safe_actions(config, :member_actions).map { |a| [a, :member] })
@@ -9,6 +9,10 @@ module ActiveadminMcp
9
9
  class ActionDefinition
10
10
  KINDS = %i[member collection batch].freeze
11
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
+
12
16
  # JSON Schema scalar types an application may declare.
13
17
  TYPES = %i[string integer number boolean array object].freeze
14
18
 
@@ -24,18 +28,19 @@ module ActiveadminMcp
24
28
 
25
29
  attr_reader :config, :action, :kind, :errors
26
30
 
27
- def self.build(config:, action:, kind:)
31
+ def self.build(config:, action:, kind:, current_user: nil)
28
32
  options = action.mcp_options
29
33
  return nil unless options.is_a?(Hash)
30
34
 
31
- new(config: config, action: action, kind: kind, options: options)
35
+ new(config: config, action: action, kind: kind, options: options, current_user: current_user)
32
36
  end
33
37
 
34
- def initialize(config:, action:, kind:, options:)
38
+ def initialize(config:, action:, kind:, options:, current_user: nil)
35
39
  @config = config
36
40
  @action = action
37
41
  @kind = kind
38
42
  @options = options
43
+ @current_user = current_user
39
44
  @errors = []
40
45
  validate!
41
46
  end
@@ -48,8 +53,28 @@ module ActiveadminMcp
48
53
  @config.resource_class.name
49
54
  end
50
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.
51
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
52
73
  "#{resource_name.underscore.tr('/', '_')}_#{action_name}"
74
+ .downcase
75
+ .gsub(/[^a-z0-9_-]+/, "_")
76
+ .squeeze("_")
77
+ .delete_suffix("_")
53
78
  end
54
79
 
55
80
  def description
@@ -60,10 +85,32 @@ module ActiveadminMcp
60
85
  @options[:permission]
61
86
  end
62
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.
63
103
  def http_verb
64
104
  return :post if @kind == :batch
65
105
 
66
- Array(@action.http_verb).first&.to_sym || :get
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)
67
114
  end
68
115
 
69
116
  # Declared params, with batch actions inheriting their types from the
@@ -89,14 +136,57 @@ module ActiveadminMcp
89
136
 
90
137
  def inherited_params
91
138
  return {} unless @kind == :batch
92
- return {} unless @action.respond_to?(:inputs)
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)
93
173
 
94
174
  form = @action.inputs
95
- return {} unless form.is_a?(Hash)
175
+ return form if form.is_a?(Hash)
176
+ return nil unless form.is_a?(Proc)
96
177
 
97
- form.each_with_object({}) do |(name, widget), acc|
98
- acc[name.to_sym] = { type: FORM_TYPES.fetch(widget.to_sym, :string) }
99
- end
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
100
190
  end
101
191
 
102
192
  def validate!
@@ -105,7 +195,10 @@ module ActiveadminMcp
105
195
 
106
196
  reserved = reserved_param_name
107
197
  form_keys = batch_form_keys
108
- params.each do |name, spec|
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|
109
202
  unless spec.is_a?(Hash)
110
203
  @errors << "#{tool_name}: param #{name} must be a Hash"
111
204
  next
@@ -142,6 +235,27 @@ module ActiveadminMcp
142
235
  end
143
236
 
144
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(', ')})"
145
259
  end
146
260
 
147
261
  # The permitted param keys for a batch action's `inputs` (its `form:`
@@ -32,6 +32,9 @@ module ActiveadminMcp
32
32
 
33
33
  dispatcher = ControllerDispatcher.new(config: @definition.config, current_user: @current_user)
34
34
 
35
+ unavailable = display_refusal(dispatcher)
36
+ return { error: unavailable } if unavailable
37
+
35
38
  refusal = permission_refusal(dispatcher, record, parsed)
36
39
  return { error: refusal } if refusal
37
40
 
@@ -64,6 +67,24 @@ module ActiveadminMcp
64
67
  .authorized?(@definition.action_name, subject)
65
68
  end
66
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
+
67
88
  # Evaluated the way ActiveAdmin evaluates batch action `:if` procs, so
68
89
  # current_admin_user, can? and the usual admin helpers are in scope.
69
90
  # Returns a refusal message, or nil when the proc allows the call.
@@ -14,6 +14,50 @@ module ActiveadminMcp
14
14
  end
15
15
  end
16
16
 
17
+ # Records MCP annotations declared against a resource for actions it does
18
+ # not declare inline — typically because a shared concern declares them,
19
+ # and one description could not suit every resource that includes it.
20
+ #
21
+ # They live on the resource config rather than on the action, so a
22
+ # declaration may appear above or below the `include` that brings the
23
+ # action in, and so they are discarded with the config when ActiveAdmin
24
+ # reloads in development.
25
+ module ResourceAnnotations
26
+ def mcp_annotations
27
+ @mcp_annotations ||= []
28
+ end
29
+ end
30
+
31
+ module McpActionDsl
32
+ # Annotates an already-declared action so it is exposed as an MCP tool.
33
+ # Never declares the action itself: naming one that does not exist is
34
+ # reported when the catalog is built, not here, because the action may
35
+ # legitimately be declared after this call.
36
+ def mcp_action(name, kind: nil, **options)
37
+ config.mcp_annotations << {
38
+ action_name: name.to_sym,
39
+ kind: kind&.to_sym,
40
+ options: options,
41
+ }
42
+ end
43
+ end
44
+
45
+ # Installed separately from, and earlier than, apply!. A registration block
46
+ # calls mcp_action while ActiveAdmin loads its resources, which is before
47
+ # ActiveAdmin.after_load fires — so waiting for that hook would mean the
48
+ # method did not exist at the only moment anybody calls it.
49
+ #
50
+ # ActiveAdmin::Resource and ActiveAdmin::ResourceDSL both exist as soon as
51
+ # ActiveAdmin is required, so there is nothing to wait for. Idempotent:
52
+ # including a module twice is a no-op.
53
+ def self.apply_dsl!
54
+ return false unless defined?(::ActiveAdmin::Resource) && defined?(::ActiveAdmin::ResourceDSL)
55
+
56
+ ::ActiveAdmin::Resource.include(ResourceAnnotations)
57
+ ::ActiveAdmin::ResourceDSL.include(McpActionDsl)
58
+ true
59
+ end
60
+
17
61
  # ActiveAdmin::BatchAction only exists once ActiveAdmin's before_load hooks
18
62
  # have run, so this is called from ActiveAdmin.after_load rather than at
19
63
  # require time.
@@ -17,7 +17,13 @@ module ActiveadminMcp
17
17
 
18
18
  initializer "activeadmin_mcp.active_admin_ext" do
19
19
  ActiveSupport.on_load(:after_initialize) do
20
- ActiveAdmin.after_load { ActiveadminMcp::ActiveAdminExt.apply! } if defined?(::ActiveAdmin)
20
+ next unless defined?(::ActiveAdmin)
21
+
22
+ # The DSL has to exist before ActiveAdmin loads the registrations that
23
+ # call it; the option readers only have to exist before the catalog is
24
+ # read, and ActiveAdmin::BatchAction does not exist until load time.
25
+ ActiveadminMcp::ActiveAdminExt.apply_dsl!
26
+ ActiveAdmin.after_load { ActiveadminMcp::ActiveAdminExt.apply! }
21
27
  end
22
28
  end
23
29
  end
@@ -29,8 +29,12 @@ module ActiveadminMcp
29
29
  refusal = write_refusal(write_action)
30
30
  return refusal if refusal
31
31
 
32
+ # An empty result is not the same as no form block, but it means the same
33
+ # thing here: ActiveAdmin's own default form is a bare `f.inputs` that
34
+ # Formtastic expands only at render time, so a block written that way has
35
+ # nothing in it to read and the permitted params are all there is.
32
36
  declared = declared_inputs
33
- return describe(action, "form", declared) if declared
37
+ return describe(action, "form", declared) if declared&.any?
34
38
 
35
39
  permitted = permitted_inputs
36
40
  return permit_params_refusal unless permitted
@@ -114,9 +118,13 @@ module ActiveadminMcp
114
118
  names.map { |name| { name: name } }
115
119
  end
116
120
 
121
+ # The controller carries the MCP user: ActiveAdmin instance_execs a
122
+ # block-form permit_params on it, so a block reading current_admin_user
123
+ # raises on a bare instance and the resource looks unwritable.
117
124
  def permitted_names
118
125
  param_key = @config.param_key.to_sym
119
- controller = @config.controller.new
126
+ controller = ControllerDispatcher.new(config: @config, current_user: @current_user)
127
+ .controller_with_mcp_user
120
128
  controller.params = ActionController::Parameters.new(
121
129
  param_key => @resource[:model].column_names.index_with { nil }
122
130
  )
@@ -32,7 +32,14 @@ module ActiveadminMcp
32
32
  self
33
33
  end
34
34
 
35
- def inputs(*_args, **_opts, &block)
35
+ # `inputs for: :author` scopes its fields to an association, the same way
36
+ # has_many does; only an unscoped `inputs` groups fields of the record
37
+ # itself. Descending into a scoped one would advertise the associated
38
+ # record's fields as attributes of the record being written.
39
+ def inputs(*_args, **options, &block)
40
+ association = options[:for]
41
+ return nest(association, &block) if association
42
+
36
43
  instance_exec(self, &block) if block
37
44
  self
38
45
  end
@@ -41,11 +48,7 @@ module ActiveadminMcp
41
48
  # can tell an association's fields from the record's own. Flattening them
42
49
  # would advertise `body` as an attribute of the parent record.
43
50
  def has_many(name, *_args, **_opts, &block)
44
- return self unless name.respond_to?(:to_sym)
45
- return self if declared?(name.to_sym)
46
-
47
- @inputs << { name: name.to_sym, nested: block ? self.class.new.collect(&block) : [] }
48
- self
51
+ nest(name, &block)
49
52
  end
50
53
 
51
54
  def method_missing(_name, *_args, **_opts, &block)
@@ -59,6 +62,17 @@ module ActiveadminMcp
59
62
 
60
63
  private
61
64
 
65
+ # `for:` may name the association or give it as [name, object]; only the
66
+ # name says anything to a client.
67
+ def nest(association, &block)
68
+ name = association.is_a?(Array) ? association.first : association
69
+ return self unless name.respond_to?(:to_sym)
70
+ return self if declared?(name.to_sym)
71
+
72
+ @inputs << { name: name.to_sym, nested: block ? self.class.new.collect(&block) : [] }
73
+ self
74
+ end
75
+
62
76
  def declared?(name)
63
77
  @inputs.any? { |input| input[:name] == name }
64
78
  end
@@ -133,9 +133,15 @@ module ActiveadminMcp
133
133
 
134
134
  # Asks the resource's own controller what it would permit, or nil when it
135
135
  # has nothing to say because permit_params was never declared.
136
+ #
137
+ # The controller carries the MCP user, because ActiveAdmin instance_execs a
138
+ # block-form permit_params on it: a block reading current_admin_user raises
139
+ # on a bare instance, and the rescue below would then report a resource
140
+ # that permits plenty as one that permits nothing.
136
141
  def resolve_permitted(attributes)
137
142
  param_key = @config.param_key.to_sym
138
- controller = @config.controller.new
143
+ controller = ControllerDispatcher.new(config: @config, current_user: @current_user)
144
+ .controller_with_mcp_user
139
145
  controller.params = ActionController::Parameters.new(param_key => attributes)
140
146
  permitted = controller.send(:permitted_params)
141
147
  scoped = permitted && permitted[param_key]
@@ -46,8 +46,9 @@ module ActiveadminMcp
46
46
  # an already-assembled list would mean those procs had already run — and
47
47
  # their values already been read — for a user authorized for none of it.
48
48
  def action_tools
49
- ActionCatalog.all.filter_map do |definition|
49
+ ActionCatalog.all(current_user: @current_user).filter_map do |definition|
50
50
  next unless authorized_to_run?(definition)
51
+ next unless offered_by_active_admin?(definition)
51
52
  next unless authorized_to_list?(definition)
52
53
 
53
54
  {
@@ -69,6 +70,23 @@ module ActiveadminMcp
69
70
  false
70
71
  end
71
72
 
73
+ # ActiveAdmin's own `:if` proc on a batch action decides whether the admin
74
+ # UI offers it. Evaluated in controller context, as ActiveAdmin evaluates
75
+ # it, so `authorized?` and `current_admin_user` are in scope. A proc
76
+ # reaching for request state it cannot have here raises, and the tool is
77
+ # hidden rather than offered past a gate we could not read.
78
+ def offered_by_active_admin?(definition)
79
+ block = definition.display_if
80
+ return true unless block
81
+
82
+ controller = ControllerDispatcher.new(config: definition.config, current_user: @current_user)
83
+ .controller_with_mcp_user
84
+ !!::MethodOrProcHelper.render_in_context(controller, block)
85
+ rescue StandardError => e
86
+ warn("[activeadmin_mcp] hiding #{definition.tool_name}: if: proc raised #{e.class}: #{e.message}")
87
+ false
88
+ end
89
+
72
90
  # Collection and batch actions whose permission proc takes no record can be
73
91
  # resolved now, so the tool is simply hidden. A member action's proc needs a
74
92
  # record, so its tool stays listed and refusal happens at call time.
@@ -182,7 +200,7 @@ module ActiveadminMcp
182
200
  end
183
201
 
184
202
  def tool_action(name, args)
185
- definition = ActionCatalog.find(name)
203
+ definition = ActionCatalog.find(name, current_user: @current_user)
186
204
  return { error: "Unknown tool: #{name}" } unless definition
187
205
 
188
206
  ActionRunner.new(definition: definition, current_user: @current_user).call(args)
@@ -1,3 +1,3 @@
1
1
  module ActiveadminMcp
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeadmin_mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - harunkumars