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.
@@ -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
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module ActiveadminMcp
4
2
  # Applies a resource's ActiveAdmin authorization adapter to MCP tool calls, so
5
3
  # reads, listings and writes obey the same rules as the admin UI.
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module ActiveadminMcp
4
2
  class Configuration
5
3
  MOUNT_STRATEGIES = %i[prepend append none].freeze
@@ -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
- # Records the field names declared by an ActiveAdmin `form do ... end` block.
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`, helper calls, conditionals — so the block is run against this
8
- # stand-in form builder. Every `input :field` records `:field`; every other
9
- # message (including unknown helpers) is swallowed and returns self, so the
10
- # block executes without a real view context. Nested `has_many` associations
11
- # are intentionally not descended into: the updater only writes flat
12
- # attributes, and descending would record association fields as top-level.
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
- attr_reader :fields
16
+ DESCRIBED_TEXT_OPTIONS = %i[label hint].freeze
15
17
 
16
18
  def initialize
17
- @fields = []
19
+ @inputs = []
18
20
  end
19
21
 
20
22
  def collect(&block)
21
23
  instance_exec(self, &block)
22
- @fields.uniq
24
+ @inputs
23
25
  end
24
26
 
25
- def input(name, *_args, **_opts, &_block)
26
- @fields << name.to_sym if name.respond_to?(:to_sym)
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
- def inputs(*_args, **_opts, &block)
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
- def has_many(*_args, **_opts)
36
- self
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