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