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,83 @@
|
|
|
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
|
+
# 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
|
+
|
|
61
|
+
# ActiveAdmin::BatchAction only exists once ActiveAdmin's before_load hooks
|
|
62
|
+
# have run, so this is called from ActiveAdmin.after_load rather than at
|
|
63
|
+
# require time.
|
|
64
|
+
def self.apply!
|
|
65
|
+
unless applicable?
|
|
66
|
+
# Without these readers every opted-in action silently vanishes from
|
|
67
|
+
# tools/list, because ActionCatalog can no longer see an mcp: option
|
|
68
|
+
# anywhere. Say so rather than shipping a feature that is quietly off.
|
|
69
|
+
warn("[activeadmin_mcp] ActiveAdmin::ControllerAction / ActiveAdmin::BatchAction not found: " \
|
|
70
|
+
"MCP action options cannot be read, so no opted-in actions will be exposed as tools.")
|
|
71
|
+
return false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
::ActiveAdmin::ControllerAction.include(ActionOptions)
|
|
75
|
+
::ActiveAdmin::BatchAction.include(ActionOptions)
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.applicable?
|
|
80
|
+
defined?(::ActiveAdmin::ControllerAction) && defined?(::ActiveAdmin::BatchAction) ? true : false
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
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,17 @@ 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
|
+
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! }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
19
29
|
end
|
|
20
30
|
end
|
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
# 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.
|
|
36
|
+
declared = declared_inputs
|
|
37
|
+
return describe(action, "form", declared) if declared&.any?
|
|
38
|
+
|
|
39
|
+
permitted = permitted_inputs
|
|
40
|
+
return permit_params_refusal unless permitted
|
|
41
|
+
|
|
42
|
+
describe(action, "permit_params", permitted)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def describe(action, source, inputs)
|
|
48
|
+
fields, groups = inputs.partition { |input| !input.key?(:nested) }
|
|
49
|
+
|
|
50
|
+
{
|
|
51
|
+
resource: @resource[:name],
|
|
52
|
+
action: action.to_s,
|
|
53
|
+
source: source,
|
|
54
|
+
attributes: fields.map { |field| attribute(field) },
|
|
55
|
+
nested: groups.map { |group| nested_group(group) },
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# An association's own fields are reported as the form declared them and
|
|
60
|
+
# are not annotated from a model: they belong to the associated record, not
|
|
61
|
+
# to the one being written.
|
|
62
|
+
def nested_group(group)
|
|
63
|
+
{ name: group[:name].to_s, attributes: group[:nested].map { |field| stringify(field) } }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def attribute(field)
|
|
67
|
+
described = stringify(field)
|
|
68
|
+
described[:type] = column_type(field[:name]) if column_type(field[:name])
|
|
69
|
+
described[:required] = true if !described.key?(:required) && presence_validated?(field[:name])
|
|
70
|
+
described
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def stringify(field)
|
|
74
|
+
field.each_with_object({}) do |(key, value), described|
|
|
75
|
+
described[key] = value.is_a?(Symbol) ? value.to_s : value
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def column_type(name)
|
|
80
|
+
@resource[:model].columns_hash[name.to_s]&.type&.to_s
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def presence_validated?(name)
|
|
84
|
+
@resource[:model].validators_on(name).any? do |validator|
|
|
85
|
+
validator.is_a?(ActiveModel::Validations::PresenceValidator)
|
|
86
|
+
end
|
|
87
|
+
rescue StandardError
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def declared_inputs
|
|
92
|
+
block = form_block
|
|
93
|
+
return nil unless block
|
|
94
|
+
|
|
95
|
+
FormFieldCollector.new.collect(&block)
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
# A form block that will not run outside a request describes nothing, but
|
|
98
|
+
# the resource's permitted params still can.
|
|
99
|
+
warn("[activeadmin_mcp] reading the #{@resource[:name]} form raised #{e.class}: #{e.message}")
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def form_block
|
|
104
|
+
return nil unless @config.respond_to?(:page_presenters)
|
|
105
|
+
|
|
106
|
+
@config.page_presenters[:form]&.block
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# ActiveAdmin stores no list of the params it permits, only a method that
|
|
110
|
+
# filters against them, so the permitted names are recovered by offering it
|
|
111
|
+
# every column the model has and seeing which survive. A resource that
|
|
112
|
+
# never declared permit_params has no such method to answer, and is
|
|
113
|
+
# reported as unwritable rather than described.
|
|
114
|
+
def permitted_inputs
|
|
115
|
+
names = permitted_names
|
|
116
|
+
return nil unless names
|
|
117
|
+
|
|
118
|
+
names.map { |name| { name: name } }
|
|
119
|
+
end
|
|
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.
|
|
124
|
+
def permitted_names
|
|
125
|
+
param_key = @config.param_key.to_sym
|
|
126
|
+
controller = ControllerDispatcher.new(config: @config, current_user: @current_user)
|
|
127
|
+
.controller_with_mcp_user
|
|
128
|
+
controller.params = ActionController::Parameters.new(
|
|
129
|
+
param_key => @resource[:model].column_names.index_with { nil }
|
|
130
|
+
)
|
|
131
|
+
permitted = controller.send(:permitted_params)
|
|
132
|
+
scoped = permitted && permitted[param_key]
|
|
133
|
+
scoped&.keys&.map(&:to_sym)
|
|
134
|
+
rescue StandardError
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def write_refusal(write_action)
|
|
139
|
+
unless @config.defined_actions.include?(write_action)
|
|
140
|
+
return error("Resource #{REFUSALS[write_action]}: #{@resource[:name]}")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
return if Authorization.for(@config, @current_user)
|
|
144
|
+
.authorized?(write_action, @config.resource_class)
|
|
145
|
+
|
|
146
|
+
error("Not authorized to #{write_action} #{@resource[:name]}")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def permit_params_refusal
|
|
150
|
+
error("Resource declares no permit_params, so nothing may be written: #{@resource[:name]}")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def error(message)
|
|
154
|
+
{ error: message }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -1,39 +1,54 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
module ActiveadminMcp
|
|
4
|
-
#
|
|
2
|
+
# Reads an ActiveAdmin `form do ... end` block and reports what each input
|
|
3
|
+
# tells a client about how to fill the field in.
|
|
5
4
|
#
|
|
6
5
|
# ActiveAdmin form blocks are arbitrary Formtastic DSL — `input`, `inputs`,
|
|
7
|
-
# `actions`,
|
|
8
|
-
# stand-in form builder. Every `input :field`
|
|
9
|
-
# message (including
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
6
|
+
# `actions`, application helpers, conditionals — so the block is run against
|
|
7
|
+
# this stand-in form builder rather than parsed. Every `input :field` is
|
|
8
|
+
# recorded; every other message (including helpers that would need a view
|
|
9
|
+
# context we do not have) is swallowed and returns self, so the block runs to
|
|
10
|
+
# completion outside a request.
|
|
11
|
+
#
|
|
12
|
+
# Only the options that describe the field to whoever is filling it in are
|
|
13
|
+
# kept. Presentation options (`input_html:`, `wrapper_html:` and the rest)
|
|
14
|
+
# say nothing an MCP client can act on and are dropped.
|
|
13
15
|
class FormFieldCollector
|
|
14
|
-
|
|
16
|
+
DESCRIBED_TEXT_OPTIONS = %i[label hint].freeze
|
|
15
17
|
|
|
16
18
|
def initialize
|
|
17
|
-
@
|
|
19
|
+
@inputs = []
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def collect(&block)
|
|
21
23
|
instance_exec(self, &block)
|
|
22
|
-
@
|
|
24
|
+
@inputs
|
|
23
25
|
end
|
|
24
26
|
|
|
25
|
-
def input(name, *_args, **
|
|
26
|
-
|
|
27
|
+
def input(name, *_args, **options, &_block)
|
|
28
|
+
return self unless name.respond_to?(:to_sym)
|
|
29
|
+
return self if declared?(name.to_sym)
|
|
30
|
+
|
|
31
|
+
@inputs << describe(name.to_sym, options)
|
|
27
32
|
self
|
|
28
33
|
end
|
|
29
34
|
|
|
30
|
-
|
|
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
|
+
|
|
31
43
|
instance_exec(self, &block) if block
|
|
32
44
|
self
|
|
33
45
|
end
|
|
34
46
|
|
|
35
|
-
|
|
36
|
-
|
|
47
|
+
# Recorded as a group of its own rather than descended into, so a client
|
|
48
|
+
# can tell an association's fields from the record's own. Flattening them
|
|
49
|
+
# would advertise `body` as an attribute of the parent record.
|
|
50
|
+
def has_many(name, *_args, **_opts, &block)
|
|
51
|
+
nest(name, &block)
|
|
37
52
|
end
|
|
38
53
|
|
|
39
54
|
def method_missing(_name, *_args, **_opts, &block)
|
|
@@ -44,5 +59,57 @@ module ActiveadminMcp
|
|
|
44
59
|
def respond_to_missing?(_name, _include_private = false)
|
|
45
60
|
true
|
|
46
61
|
end
|
|
62
|
+
|
|
63
|
+
private
|
|
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
|
+
|
|
76
|
+
def declared?(name)
|
|
77
|
+
@inputs.any? { |input| input[:name] == name }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def describe(name, options)
|
|
81
|
+
described = { name: name }
|
|
82
|
+
|
|
83
|
+
described[:as] = options[:as].to_sym if scalar_name?(options[:as])
|
|
84
|
+
described[:required] = options[:required] if [true, false].include?(options[:required])
|
|
85
|
+
|
|
86
|
+
DESCRIBED_TEXT_OPTIONS.each do |key|
|
|
87
|
+
described[key] = options[key].to_s if scalar_name?(options[key])
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
values = allowed_values(options[:collection])
|
|
91
|
+
described[:collection] = values if values
|
|
92
|
+
|
|
93
|
+
described
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A helper the collector swallowed comes back as the collector itself, so
|
|
97
|
+
# anything that is not plain text is not something to report as a label.
|
|
98
|
+
def scalar_name?(value)
|
|
99
|
+
value.is_a?(String) || value.is_a?(Symbol)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Only a literal array is resolved. A relation would mean firing a query
|
|
103
|
+
# from what is meant to be a description, and can be arbitrarily large; a
|
|
104
|
+
# proc usually needs the view context this collector does not have. Either
|
|
105
|
+
# is omitted rather than evaluated.
|
|
106
|
+
def allowed_values(collection)
|
|
107
|
+
return nil unless collection.is_a?(Array)
|
|
108
|
+
|
|
109
|
+
values = collection.map { |entry| entry.is_a?(Array) ? entry.last : entry }
|
|
110
|
+
return nil unless values.all? { |value| scalar_name?(value) || value.is_a?(Numeric) }
|
|
111
|
+
|
|
112
|
+
values
|
|
113
|
+
end
|
|
47
114
|
end
|
|
48
115
|
end
|