Skip to content

Design: member/batch actions, record creation, and form descriptionΒ #8

Description

@lloydwatkin

πŸ€– Design spec drafted with Claude Code. Also committed to the repo at docs/superpowers/specs/2026-09-18-mcp-actions-and-forms-design.md (commit 65aa20d).


Status: Approved for planning

Problem

activeadmin_mcp exposes three tools β€” list_resources, query and update.
An MCP client can therefore read an ActiveAdmin application and change fields on
existing records, but it cannot do any of the things an admin user spends most
of their time doing: creating records, and running the bespoke actions a
resource defines.

The driving case is a create_warning form on a volunteer page: a
member_action that renders a custom form and a POST that handles the submit.
Nothing about that action is reachable over MCP today, and nothing about it is
introspectable β€” ControllerAction stores only the action's name and HTTP verb.

A secondary gap: FormFieldCollector records only flat input names, so a
client calling update has to guess field types and allowed values from column
names.

Goals

  1. Expose selected member_action, collection_action and batch_action
    definitions as MCP tools, and execute them faithfully.
  2. Add a create tool for standard ActiveAdmin resource forms.
  3. Describe standard forms richly enough that a client stops guessing.

Non-goals

  • Exposing actions by default. Everything here is opt-in.
  • Returning rendered HTML bodies to the client.
  • Annotating actions the application does not declare inline (see
    "Deferred" below).

Background: what ActiveAdmin already gives us

Findings from ActiveAdmin 3.5.2, which shape the whole design.

Member and collection actions carry no schema. ResourceDSL#member_action
delegates to #action, which stores ControllerAction.new(name, options) and
define_methods the block onto the controller. ControllerAction exposes only
name and http_verb. There is nothing to introspect, which is why the
application must supply a schema.

Batch actions already carry one. BatchAction keeps sym, title, block
and an options hash including form: (e.g. form: { reason: :text }), an :if
proc and a confirm message. ActiveAdmin's own batch_action controller method
does instance_exec selection, inputs, &block after slicing inputs to the
declared form: keys.

Unknown option keys ride along inert. ResourceDSL#action does not validate
options β€” it deletes :title and hands the rest to ControllerAction. The
router reads only name and http_verb and never splats options into route
definitions. BatchAction stores its options the same way. An mcp: key is
therefore carried without effect on all three action kinds.

Design

1. Declaration: an inline mcp: option

Opt-in and schema are declared inline on the action itself:

ActiveAdmin.register Volunteer do
  member_action :create_warning, method: :post, mcp: {
    description: "Record a warning against a volunteer",
    permission: ->(volunteer) { volunteer.active? && can?(:warn, volunteer) },
    params: {
      reason:   { type: :string, required: true,
                  hint: "Short free-text summary shown to the volunteer" },
      severity: { type: :string, enum: %w[low medium high] },
      category: { type: :string,
                  hint: "Must match an existing warning category",
                  suggestions: -> { WarningCategory.pluck(:name) } }
    }
  } do
    # existing action body, untouched
  end

  batch_action :suspend, form: { reason: :text },
                         mcp: { description: "Suspend the selected volunteers" }
end

No mcp: key means the action is never exposed.

A single declaration site means the schema cannot drift from the action it
describes, and there is no name to resolve and no ordering constraint between
the declaration and the action.

Engine footprint. Two reader mixins, applied from the engine initializer:
ActiveAdmin::ControllerAction and ActiveAdmin::BatchAction each gain an
options reader. No new DSL method, no storage on the resource.

Hints and values. hint: is always static text. Only suggested values may be
dynamic, and the binding/advisory split is deliberate:

Key Evaluated Behaviour
enum: (static array) at declaration Binding. Validated server-side before dispatch. Emitted as JSON Schema enum.
suggestions: (proc) at each tools/list Advisory only, never enforced. Emitted as examples plus a description line.

A suggestions: proc that raises drops its suggestions and keeps the tool; it
never breaks the listing.

Param types. type: accepts the JSON Schema scalar names as symbols β€”
:string, :integer, :number, :boolean, :array, :object β€” and maps
straight through. An unrecognised type is a declaration error, reported when the
tool is listed. required: true places the param in the schema's required
array and is validated before dispatch.

2. Tool exposure

Each declared action becomes its own MCP tool named <resource>_<action> β€”
volunteer_create_warning β€” rather than one generic run_action. A named tool
with a typed inputSchema and description is substantially more usable by a
model, and because exposure is opt-in the tool list stays small. Names colliding
with the built-in tools are refused.

Tools are listed only when the authenticated user passes the resource's
authorization check, so tools/list is already user-specific β€” the same
posture list_resources has today.

Schema assembly by kind:

  • :member β€” required id, plus declared params.
  • :collection β€” declared params only.
  • :batch β€” required ids array, param types inherited from the
    BatchAction's existing form: hash, with the mcp: declaration layering
    descriptions and hints on top.

ActionDefinition is a straight wrapper over (config, action, mcp_options).

3. Authorization

The permission: proc is an additional gate, never a replacement. It can
only narrow access, never widen it. Call order:

  1. ActiveAdmin's authorization adapter β€” the same check query and update
    make today. Must pass.
  2. The permission: proc. Must pass.
  3. Dispatch, during which the controller's own before_action chain and
    ActiveAdmin's controller-level authorization run again as normal.

A resource the MCP user cannot touch stays untouchable whether or not a
permission: proc exists.

The proc is evaluated via ActiveAdmin's own
MethodOrProcHelper.render_in_context(controller, proc, record) β€” the same
mechanism that evaluates batch-action :if procs β€” so current_admin_user,
can? and the usual admin helpers are in scope. It is arity-tolerant: member
actions receive the record, collection actions receive nothing, batch actions
may optionally receive the id list.

Returning false refuses with a generic message. Returning a String refuses
with that string as the reason, giving the agent something actionable.

On listing: a proc taking no record (collection and batch) is evaluated at
tools/list time and the tool is hidden if it refuses. A member-action proc
needs a record, so the tool stays listed and refusal happens at call time.

4. Execution: ActionRunner

find definition -> validate params -> AA authorization -> permission proc -> dispatch -> capture

Building the call. The request is synthesized rather than routed:
Rack::MockRequest.env_for with the action's http_verb and validated params,
wrapped in an ActionDispatch::Request, with path_parameters set to
{ controller:, action:, id: }. The path is derived from the resource's own
ActiveAdmin route helpers so redirect_to and url_for inside the action block
resolve correctly. We then use the instance form β€”
set_request! / set_response! / process(action_name) β€” rather than the
class-level dispatch, precisely so state can be injected first.

Injecting the MCP user. On the controller instance we define singleton
methods for ActiveadminMcp.config.current_user_method (default
current_admin_user) and current_active_admin_user, both returning the
already-authenticated MCP user, and neutralise the namespace's
authentication_method callback (typically Devise's authenticate_admin_user!)
to a no-op β€” MCP has authenticated by bearer token already, and that callback
would otherwise redirect to a login page. This is the most delicate part of the
design and gets its own spec.

Batch actions enter through ActiveAdmin's own batch_action controller
method with params[:batch_action], params[:collection_selection] and JSON
params[:batch_action_inputs], so AA's slicing of inputs to declared form:
keys applies unchanged.

Capturing the result. Success returns { status:, redirect_to:, flash: }.
Actions that render rather than redirect return
{ status:, rendered: true, flash: } β€” deliberately not the HTML body, which
would be large and mostly chrome. Exceptions during dispatch are rescued and
returned as a tool error, never leaked as a 500.

5. Writes go through dispatch too

create and update both dispatch through the real controller, using the same
machinery as actions, so ActiveAdmin's after_build, before_create and
after_update callbacks fire.

This is a behaviour change to the existing update tool, which currently
calls record.update directly and skips those callbacks. It must be called out
in the CHANGELOG.

Consequence to resolve during implementation, not to assume now: dispatching
means ActiveAdmin's own permitted_params applies natively, so much of
RecordUpdater's bespoke permit-params resolution may become dead code. The
from_form fallback β€” for resources that declare writable fields through a
form do ... end block, where AA's default permitted_params returns nil β€”
covers a real case. Phase 2 determines whether that fallback still earns its
place and records the finding either way.

6. Form description

FormFieldCollector currently discards everything but the field name. It gains
the ability to record each input's options (as:, required:, collection:,
hint:, label:) and to note has_many blocks as nested rather than silently
skipping them. Its existing fields method keeps returning bare names so
current call sites are untouched; a new inputs method returns the detail.

A describe_form tool takes (resource, action: "new" | "edit") and merges that
detail with the model's column types and presence validators.

collection: is frequently a proc or relation evaluated in view context and is
often unresolvable outside a request. It must fail safe: omit the values, never
raise.

Testing

The existing suite is entirely double-based and spec_helper never loads
ActiveAdmin. That is fine for the current code but cannot honestly cover the two
riskiest new things: the mixins reaching into @options, and controller
dispatch.

  • Unit specs in the existing double style for ActionDefinition, schema
    assembly, param validation, the permission: proc, and FormFieldCollector.
  • A new spec/support/active_admin.rb boots a minimal real ActiveAdmin
    registration, used by the ActionRunner, dispatch and mixin specs.
  • A guard spec asserting ActiveAdmin still carries unknown option keys through
    untouched, so a future ActiveAdmin bump fails loudly in our suite rather than
    quietly in a user's admin.

TDD throughout.

Known risks

  • Coupling to ActiveAdmin internals. We rely on AA tolerating unknown option
    keys, and on reading @options via a mixin. True in 3.5.2 and structurally
    likely to hold, since options are simply stashed on the action object. The
    guard spec makes a regression loud.
  • Rendering actions. Flash requires a session in the Rack env, which we
    seed. GET actions that render full ActiveAdmin views may still fail for want
    of a complete view context. Redirect-style (submit-side) actions are the
    supported case; rendering actions are best-effort, and the README will say so.
  • update behaviour change. See section 5.

Phasing

Each phase is independently shippable.

  1. Actions end to end β€” reader mixins, ActionDefinition, schema assembly,
    ActionRunner, tools/list integration. The driving case.
  2. Writes through dispatch β€” create tool, update moved onto dispatch,
    PermittedAttributes question resolved.
  3. Form description β€” FormFieldCollector upgrade, describe_form tool.

README tool table and CHANGELOG updated with each phase.

Deferred

Annotating an action the application does not declare inline β€” one defined in a
shared concern or by another gem. If the need arises, a standalone mcp_action
DSL can be added later feeding the same ActionDefinition.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions