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.
- checksums.yaml +4 -4
- data/README.md +373 -32
- data/app/controllers/activeadmin_mcp/mcp_controller.rb +0 -2
- data/app/models/activeadmin_mcp/api_token.rb +0 -2
- data/config/routes.rb +0 -2
- data/lib/activeadmin_mcp/action_catalog.rb +165 -0
- data/lib/activeadmin_mcp/action_definition.rb +297 -0
- data/lib/activeadmin_mcp/action_params.rb +78 -0
- data/lib/activeadmin_mcp/action_runner.rb +184 -0
- data/lib/activeadmin_mcp/action_schema.rb +66 -0
- data/lib/activeadmin_mcp/active_admin_ext.rb +83 -0
- data/lib/activeadmin_mcp/authorization.rb +0 -2
- data/lib/activeadmin_mcp/configuration.rb +0 -2
- data/lib/activeadmin_mcp/controller_dispatcher.rb +138 -0
- data/lib/activeadmin_mcp/engine.rb +12 -2
- data/lib/activeadmin_mcp/form_description.rb +157 -0
- data/lib/activeadmin_mcp/form_field_collector.rb +84 -17
- data/lib/activeadmin_mcp/record_writer.rb +167 -0
- data/lib/activeadmin_mcp/request_handler.rb +167 -38
- data/lib/activeadmin_mcp/resource_registry.rb +0 -2
- data/lib/activeadmin_mcp/version.rb +1 -3
- data/lib/activeadmin_mcp.rb +9 -3
- data/lib/generators/activeadmin_mcp/install/install_generator.rb +0 -2
- data/lib/generators/activeadmin_mcp/install/templates/initializer.rb +5 -2
- data/lib/generators/activeadmin_mcp/install/templates/mcp_api_tokens.rb +0 -2
- data/lib/generators/activeadmin_mcp/install/templates/migration.rb.erb +0 -2
- metadata +18 -10
- data/lib/activeadmin_mcp/record_updater.rb +0 -108
|
@@ -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
|