activeadmin_mcp 0.0.4 → 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 +280 -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 +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,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
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
module ActiveadminMcp
|
|
4
2
|
class Engine < ::Rails::Engine
|
|
5
3
|
isolate_namespace ActiveadminMcp
|
|
@@ -16,5 +14,11 @@ module ActiveadminMcp
|
|
|
16
14
|
end
|
|
17
15
|
end
|
|
18
16
|
end
|
|
17
|
+
|
|
18
|
+
initializer "activeadmin_mcp.active_admin_ext" do
|
|
19
|
+
ActiveSupport.on_load(:after_initialize) do
|
|
20
|
+
ActiveAdmin.after_load { ActiveadminMcp::ActiveAdminExt.apply! } if defined?(::ActiveAdmin)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
19
23
|
end
|
|
20
24
|
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
module ActiveadminMcp
|
|
2
|
+
# Describes the form behind a resource's create or update action richly
|
|
3
|
+
# enough that an MCP client stops guessing field types and allowed values
|
|
4
|
+
# from column names.
|
|
5
|
+
#
|
|
6
|
+
# The description is read from the resource's own `form do ... end` block
|
|
7
|
+
# when it declares one. When it does not, ActiveAdmin renders a bare
|
|
8
|
+
# `f.inputs` that Formtastic only expands at render time — there is nothing
|
|
9
|
+
# to introspect — so the description is derived from the resource's
|
|
10
|
+
# `permit_params` instead, which is what `create` and `update` enforce
|
|
11
|
+
# anyway. The payload says which of the two it is.
|
|
12
|
+
#
|
|
13
|
+
# Either way each field is annotated from the model: the column type it is
|
|
14
|
+
# stored in, and whether the model validates its presence.
|
|
15
|
+
class FormDescription
|
|
16
|
+
WRITE_ACTIONS = { "new" => :create, "edit" => :update }.freeze
|
|
17
|
+
REFUSALS = { create: "is not creatable", update: "is not editable" }.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(resource:, current_user:)
|
|
20
|
+
@resource = resource
|
|
21
|
+
@current_user = current_user
|
|
22
|
+
@config = resource[:config]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(action: "new")
|
|
26
|
+
write_action = WRITE_ACTIONS[action.to_s]
|
|
27
|
+
return error(%(Unknown form action: #{action} (expected "new" or "edit"))) unless write_action
|
|
28
|
+
|
|
29
|
+
refusal = write_refusal(write_action)
|
|
30
|
+
return refusal if refusal
|
|
31
|
+
|
|
32
|
+
declared = declared_inputs
|
|
33
|
+
return describe(action, "form", declared) if declared
|
|
34
|
+
|
|
35
|
+
permitted = permitted_inputs
|
|
36
|
+
return permit_params_refusal unless permitted
|
|
37
|
+
|
|
38
|
+
describe(action, "permit_params", permitted)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def describe(action, source, inputs)
|
|
44
|
+
fields, groups = inputs.partition { |input| !input.key?(:nested) }
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
resource: @resource[:name],
|
|
48
|
+
action: action.to_s,
|
|
49
|
+
source: source,
|
|
50
|
+
attributes: fields.map { |field| attribute(field) },
|
|
51
|
+
nested: groups.map { |group| nested_group(group) },
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# An association's own fields are reported as the form declared them and
|
|
56
|
+
# are not annotated from a model: they belong to the associated record, not
|
|
57
|
+
# to the one being written.
|
|
58
|
+
def nested_group(group)
|
|
59
|
+
{ name: group[:name].to_s, attributes: group[:nested].map { |field| stringify(field) } }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def attribute(field)
|
|
63
|
+
described = stringify(field)
|
|
64
|
+
described[:type] = column_type(field[:name]) if column_type(field[:name])
|
|
65
|
+
described[:required] = true if !described.key?(:required) && presence_validated?(field[:name])
|
|
66
|
+
described
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def stringify(field)
|
|
70
|
+
field.each_with_object({}) do |(key, value), described|
|
|
71
|
+
described[key] = value.is_a?(Symbol) ? value.to_s : value
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def column_type(name)
|
|
76
|
+
@resource[:model].columns_hash[name.to_s]&.type&.to_s
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def presence_validated?(name)
|
|
80
|
+
@resource[:model].validators_on(name).any? do |validator|
|
|
81
|
+
validator.is_a?(ActiveModel::Validations::PresenceValidator)
|
|
82
|
+
end
|
|
83
|
+
rescue StandardError
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def declared_inputs
|
|
88
|
+
block = form_block
|
|
89
|
+
return nil unless block
|
|
90
|
+
|
|
91
|
+
FormFieldCollector.new.collect(&block)
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
# A form block that will not run outside a request describes nothing, but
|
|
94
|
+
# the resource's permitted params still can.
|
|
95
|
+
warn("[activeadmin_mcp] reading the #{@resource[:name]} form raised #{e.class}: #{e.message}")
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def form_block
|
|
100
|
+
return nil unless @config.respond_to?(:page_presenters)
|
|
101
|
+
|
|
102
|
+
@config.page_presenters[:form]&.block
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# ActiveAdmin stores no list of the params it permits, only a method that
|
|
106
|
+
# filters against them, so the permitted names are recovered by offering it
|
|
107
|
+
# every column the model has and seeing which survive. A resource that
|
|
108
|
+
# never declared permit_params has no such method to answer, and is
|
|
109
|
+
# reported as unwritable rather than described.
|
|
110
|
+
def permitted_inputs
|
|
111
|
+
names = permitted_names
|
|
112
|
+
return nil unless names
|
|
113
|
+
|
|
114
|
+
names.map { |name| { name: name } }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def permitted_names
|
|
118
|
+
param_key = @config.param_key.to_sym
|
|
119
|
+
controller = @config.controller.new
|
|
120
|
+
controller.params = ActionController::Parameters.new(
|
|
121
|
+
param_key => @resource[:model].column_names.index_with { nil }
|
|
122
|
+
)
|
|
123
|
+
permitted = controller.send(:permitted_params)
|
|
124
|
+
scoped = permitted && permitted[param_key]
|
|
125
|
+
scoped&.keys&.map(&:to_sym)
|
|
126
|
+
rescue StandardError
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def write_refusal(write_action)
|
|
131
|
+
unless @config.defined_actions.include?(write_action)
|
|
132
|
+
return error("Resource #{REFUSALS[write_action]}: #{@resource[:name]}")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
return if Authorization.for(@config, @current_user)
|
|
136
|
+
.authorized?(write_action, @config.resource_class)
|
|
137
|
+
|
|
138
|
+
error("Not authorized to #{write_action} #{@resource[:name]}")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def permit_params_refusal
|
|
142
|
+
error("Resource declares no permit_params, so nothing may be written: #{@resource[:name]}")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def error(message)
|
|
146
|
+
{ error: message }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|