Skip to content

Describe ActiveAdmin forms over MCP with a describe_form tool - #14

Merged
lloydwatkin merged 1 commit into
mainfrom
implement-issue-8-phase-3
Sep 19, 2026
Merged

lloydwatkin merged 1 commit into
mainfrom
implement-issue-8-phase-3

Conversation

@lloydwatkin

Copy link
Copy Markdown
Member

🤖 Phase 3 of the design in #8 — form description, and the last of the three phases.

The gap

A client calling create or update had to guess field types and allowed values from column names. FormFieldCollector recorded only flat input names, and phase 2 removed its last consumer.

What this does

describe_form(resource:, action: "new" | "edit"). Reads the resource's own form do … end block when it declares one, reporting each input's as:, label:, hint: and — when the collection: is a literal array — its allowed values.

Resources with no form block get a derived description. ActiveAdmin renders a bare f.inputs for those, which Formtastic only expands at render time, so there is genuinely nothing to introspect. The description falls back to the resource's permit_params, which is what create and update enforce anyway. The payload's source says which of the two you are reading.

Either way, every field is annotated from the model with the column type it is stored in and whether the model validates its presence. has_many blocks are reported under nested rather than flattened in with the record's own fields.

FormFieldCollector returns, rebuilt for this purpose rather than for the bare names phase 2 removed the last consumer of. It records input options, recurses into has_many blocks, and still drops presentation-only options (input_html:, wrapper_html:) that say nothing an MCP client can act on.

Two deliberate limits

  • A collection: that is an ActiveRecord::Relation or a proc is omitted rather than evaluated. Describing a form should not fire a query, and a relation can be arbitrarily large. An action's suggestions: remains the way to offer dynamic values.
  • A field a form block declares but permit_params omits is described and then silently dropped on write. Fixing it means reaching into RecordWriter's permit resolution, and it cannot arise on the permit_params fallback path. Happy to add it if you'd rather.

Authorization

action: selects the gate, not the shape — ActiveAdmin uses one form block for both. "new" requires the resource to register create and pass create authorization; "edit" requires update. Describing a form the user could never submit tells them nothing actionable and discloses the shape of a resource they cannot write, so it is refused with the same messages create and update already use.

Testing

  • Unit: 206 examples green. A pure spec for the collector, and a FormDescription spec against the real ActiveAdmin harness — which gains a Shift resource declaring an explicit form block (with a has_many), so both source paths and the nested case are covered.
  • E2E: 51 examples green. A new Review fixture resource is the only one declaring a form block, so the declared-form path is exercised end to end; Post covers the permit_params fallback, and Author/Tag the two refusals.
  • Every new e2e example was mutation-checked across four mutation runs — stripping the form's options, removing the form block entirely, giving Tag a permit_params, adding a form block to Post, making Author creatable, widening Post's permitted params and dropping its validation. Each targeted example failed; fixtures restored and both suites re-run green.

(One incidental find: the fixture resource was originally Comment, which collides with ActiveAdmin's own built-in ActiveAdmin::Comment and fails the app build with ConfigMismatch. Renamed to Review.)

README gains a "Describing a form" section and a tool-table row; CHANGELOG records the addition.

🤖 Generated with Claude Code

Phase 3 of the actions-and-forms design (#8), and the last of them.

A client calling create or update had to guess field types and allowed values
from column names. describe_form reads the resource's own `form do ... end`
block when it declares one, reporting each input's as:, label:, hint: and, when
the collection: is a literal array, its allowed values.

Most resources declare no form block. ActiveAdmin renders a bare `f.inputs` for
those, which Formtastic only expands at render time, so there is nothing to
introspect; the description is derived from permit_params instead, which is
what create and update enforce anyway. The payload says which source it used.
Either way each field is annotated from the model with its column type and
whether the model validates its presence.

FormFieldCollector returns, rebuilt for this rather than for the bare names
phase 2 removed the last consumer of. It now records input options and reports
has_many blocks as nested groups instead of skipping them.

A collection: that is a relation or a proc is omitted rather than evaluated: a
description should not fire a query, and a relation can be arbitrarily large.

action: selects the gate, not the shape, since ActiveAdmin uses one form block
for both. Describing a form the user could never submit discloses the shape of
a resource they cannot write, so "new" requires create and "edit" requires
update, refused with the messages those tools already use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lloydwatkin
lloydwatkin merged commit 455399e into main Sep 19, 2026
3 checks passed
@lloydwatkin
lloydwatkin deleted the implement-issue-8-phase-3 branch September 19, 2026 09:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant