π€ 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
- Expose selected
member_action, collection_action and batch_action
definitions as MCP tools, and execute them faithfully.
- Add a
create tool for standard ActiveAdmin resource forms.
- 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:
- ActiveAdmin's authorization adapter β the same check
query and update
make today. Must pass.
- The
permission: proc. Must pass.
- 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.
- Actions end to end β reader mixins,
ActionDefinition, schema assembly,
ActionRunner, tools/list integration. The driving case.
- Writes through dispatch β
create tool, update moved onto dispatch,
PermittedAttributes question resolved.
- 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.
π€ 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_mcpexposes three tools βlist_resources,queryandupdate.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_warningform on a volunteer page: amember_actionthat 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 β
ControllerActionstores only the action's name and HTTP verb.A secondary gap:
FormFieldCollectorrecords only flat input names, so aclient calling
updatehas to guess field types and allowed values from columnnames.
Goals
member_action,collection_actionandbatch_actiondefinitions as MCP tools, and execute them faithfully.
createtool for standard ActiveAdmin resource forms.Non-goals
"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_actiondelegates to
#action, which storesControllerAction.new(name, options)anddefine_methods the block onto the controller.ControllerActionexposes onlynameandhttp_verb. There is nothing to introspect, which is why theapplication must supply a schema.
Batch actions already carry one.
BatchActionkeepssym,title,blockand an options hash including
form:(e.g.form: { reason: :text }), an:ifproc and a confirm message. ActiveAdmin's own
batch_actioncontroller methoddoes
instance_exec selection, inputs, &blockafter slicinginputsto thedeclared
form:keys.Unknown option keys ride along inert.
ResourceDSL#actiondoes not validateoptions β it deletes
:titleand hands the rest toControllerAction. Therouter reads only
nameandhttp_verband never splats options into routedefinitions.
BatchActionstores its options the same way. Anmcp:key istherefore carried without effect on all three action kinds.
Design
1. Declaration: an inline
mcp:optionOpt-in and schema are declared inline on the action itself:
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::ControllerActionandActiveAdmin::BatchActioneach gain anoptionsreader. No new DSL method, no storage on the resource.Hints and values.
hint:is always static text. Only suggested values may bedynamic, and the binding/advisory split is deliberate:
enum:(static array)enum.suggestions:(proc)tools/listexamplesplus a description line.A
suggestions:proc that raises drops its suggestions and keeps the tool; itnever breaks the listing.
Param types.
type:accepts the JSON Schema scalar names as symbols β:string,:integer,:number,:boolean,:array,:objectβ and mapsstraight through. An unrecognised type is a declaration error, reported when the
tool is listed.
required: trueplaces the param in the schema'srequiredarray 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 genericrun_action. A named toolwith a typed
inputSchemaand description is substantially more usable by amodel, 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/listis already user-specific β the sameposture
list_resourceshas today.Schema assembly by kind:
:memberβ requiredid, plus declared params.:collectionβ declared params only.:batchβ requiredidsarray, param types inherited from theBatchAction's existingform:hash, with themcp:declaration layeringdescriptions and hints on top.
ActionDefinitionis a straight wrapper over(config, action, mcp_options).3. Authorization
The
permission:proc is an additional gate, never a replacement. It canonly narrow access, never widen it. Call order:
queryandupdatemake today. Must pass.
permission:proc. Must pass.before_actionchain andActiveAdmin'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 samemechanism that evaluates batch-action
:ifprocs β socurrent_admin_user,can?and the usual admin helpers are in scope. It is arity-tolerant: memberactions receive the record, collection actions receive nothing, batch actions
may optionally receive the id list.
Returning
falserefuses with a generic message. Returning a String refuseswith that string as the reason, giving the agent something actionable.
On listing: a proc taking no record (collection and batch) is evaluated at
tools/listtime and the tool is hidden if it refuses. A member-action procneeds a record, so the tool stays listed and refusal happens at call time.
4. Execution:
ActionRunnerBuilding the call. The request is synthesized rather than routed:
Rack::MockRequest.env_forwith the action'shttp_verband validated params,wrapped in an
ActionDispatch::Request, withpath_parametersset to{ controller:, action:, id: }. The path is derived from the resource's ownActiveAdmin route helpers so
redirect_toandurl_forinside the action blockresolve correctly. We then use the instance form β
set_request!/set_response!/process(action_name)β rather than theclass-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(defaultcurrent_admin_user) andcurrent_active_admin_user, both returning thealready-authenticated MCP user, and neutralise the namespace's
authentication_methodcallback (typically Devise'sauthenticate_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_actioncontrollermethod with
params[:batch_action],params[:collection_selection]and JSONparams[:batch_action_inputs], so AA's slicing of inputs to declaredform: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, whichwould 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
createandupdateboth dispatch through the real controller, using the samemachinery as actions, so ActiveAdmin's
after_build,before_createandafter_updatecallbacks fire.This is a behaviour change to the existing
updatetool, which currentlycalls
record.updatedirectly and skips those callbacks. It must be called outin the CHANGELOG.
Consequence to resolve during implementation, not to assume now: dispatching
means ActiveAdmin's own
permitted_paramsapplies natively, so much ofRecordUpdater's bespoke permit-params resolution may become dead code. Thefrom_formfallback β for resources that declare writable fields through aform do ... endblock, where AA's defaultpermitted_paramsreturns nil βcovers a real case. Phase 2 determines whether that fallback still earns its
place and records the finding either way.
6. Form description
FormFieldCollectorcurrently discards everything but the field name. It gainsthe ability to record each input's options (
as:,required:,collection:,hint:,label:) and to notehas_manyblocks as nested rather than silentlyskipping them. Its existing
fieldsmethod keeps returning bare names socurrent call sites are untouched; a new
inputsmethod returns the detail.A
describe_formtool takes(resource, action: "new" | "edit")and merges thatdetail with the model's column types and presence validators.
collection:is frequently a proc or relation evaluated in view context and isoften unresolvable outside a request. It must fail safe: omit the values, never
raise.
Testing
The existing suite is entirely double-based and
spec_helpernever loadsActiveAdmin. That is fine for the current code but cannot honestly cover the two
riskiest new things: the mixins reaching into
@options, and controllerdispatch.
ActionDefinition, schemaassembly, param validation, the
permission:proc, andFormFieldCollector.spec/support/active_admin.rbboots a minimal real ActiveAdminregistration, used by the
ActionRunner, dispatch and mixin specs.untouched, so a future ActiveAdmin bump fails loudly in our suite rather than
quietly in a user's admin.
TDD throughout.
Known risks
keys, and on reading
@optionsvia a mixin. True in 3.5.2 and structurallylikely to hold, since options are simply stashed on the action object. The
guard spec makes a regression loud.
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.
updatebehaviour change. See section 5.Phasing
Each phase is independently shippable.
ActionDefinition, schema assembly,ActionRunner,tools/listintegration. The driving case.createtool,updatemoved onto dispatch,PermittedAttributesquestion resolved.FormFieldCollectorupgrade,describe_formtool.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_actionDSL can be added later feeding the same
ActionDefinition.