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 +4 -4
- data/README.md +96 -3
- data/lib/activeadmin_mcp/action_catalog.rb +109 -8
- data/lib/activeadmin_mcp/action_definition.rb +124 -10
- data/lib/activeadmin_mcp/action_runner.rb +21 -0
- data/lib/activeadmin_mcp/active_admin_ext.rb +44 -0
- data/lib/activeadmin_mcp/engine.rb +7 -1
- data/lib/activeadmin_mcp/form_description.rb +10 -2
- data/lib/activeadmin_mcp/form_field_collector.rb +20 -6
- data/lib/activeadmin_mcp/record_writer.rb +7 -1
- data/lib/activeadmin_mcp/request_handler.rb +20 -2
- data/lib/activeadmin_mcp/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3c5af58e285dcddc7105564342dc34b55531810db4ed351b10d45a1e018ab9f4
|
|
4
|
+
data.tar.gz: 5b03712e5ea784179d552b670875183324135a9835d4bceead9301433f82e8a5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
158
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
175
|
+
return form if form.is_a?(Hash)
|
|
176
|
+
return nil unless form.is_a?(Proc)
|
|
96
177
|
|
|
97
|
-
form
|
|
98
|
-
|
|
99
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|