Skip to content

Route writes through the real ActiveAdmin controller, and add a create tool - #13

Merged
lloydwatkin merged 2 commits into
mainfrom
implement-issue-8-phase-2
Sep 19, 2026
Merged

lloydwatkin merged 2 commits into
mainfrom
implement-issue-8-phase-2

Conversation

@lloydwatkin

Copy link
Copy Markdown
Member

🤖 Phase 2 of the design in #8: writes through dispatch.

What this does

create tool (new). Creates a record by dispatching the resource's own ActiveAdmin create action. Resources registered without that action are refused, permit_params decides what may be written, the namespace's authorization adapter is consulted before dispatch and again inside the controller, and every ActiveAdmin callback (before_build, before_create, before_save, …) fires.

update moved onto dispatch. It no longer calls record.update behind ActiveAdmin's back. This is a behaviour change, called out in the CHANGELOG: the controller's before_action chain, ActiveAdmin's before_update / after_update / before_save / after_save callbacks and the controller's own authorization now all run on an MCP update where they were previously skipped. Applications whose callbacks have side effects — auditing, notifications, derived columns, background jobs — will see those fire for MCP updates for the first time.

Two smaller consequences of the same change: a rejected write now reports Validation failed with the model's own messages in details, and the record echoed back has the same sensitive attributes stripped (encrypted_password, password_digest, …) that list_resources and query already omit.

ControllerDispatcher gained a block that hands the processed controller back to its caller, called whether processing succeeded or raised. That last part matters: a rejected write re-renders the admin form, and a synthesized request does not always survive that render — the unit harness proves it, blowing up on destroy_admin_user_session_path. Reading validation errors off the record rather than off the response is what keeps failure reporting honest.

The permitted-params question, resolved

The design left this open for phase 2 to decide. The from_form fallback does not earn its place, and is removed along with FormFieldCollector.

A resource that declares no permit_params cannot be saved through ActiveAdmin's own forms either: InheritedResources hands the raw unpermitted params to assign_attributes and Rails raises ActiveModel::ForbiddenAttributesError. The fallback was therefore granting MCP clients a write the admin UI itself refuses. Such a resource is now refused by name, with a message saying permit_params is missing.

A slimmed permit-params resolution survives as a pre-flight gate only — it decides whether to refuse, and reports which fields were written. The filtering itself is ActiveAdmin's job now.

FormFieldCollector is deleted rather than left as unused production code; phase 3 rewrites it substantially anyway (recording input options, not bare names) for describe_form.

Testing

  • Unit: 172 examples green. record_updater_spec is replaced by record_writer_spec, running against the real ActiveAdmin harness rather than doubles — dispatch cannot honestly be tested against mocks. The harness gained permit_params and a before_save on Volunteer, plus Note (registered read-only) and Sighting (registered writable with no permit_params).
  • E2E: 40 examples green. New fixture Tag resource with no permit_params, before_create / before_update callbacks on Post whose effects prove a write was dispatched rather than written straight to the model, and a title presence validation.
  • Every new e2e example was mutation-checked, as CLAUDE.md requires. Two mutation runs: removing the Post callbacks and adding permit_params to Tag failed 5 examples; removing Author's actions :index, :show and Post's validation failed the other 4. Fixtures restored and both suites re-run green.

README's tool table and writes section, and the CHANGELOG, are updated.

🤖 Generated with Claude Code

lloydwatkin and others added 2 commits September 19, 2026 10:06
…e tool

Phase 2 of the actions-and-forms design (#8).

`update` no longer calls `record.update` behind ActiveAdmin's back. Both it
and the new `create` tool dispatch the resource's own controller action, so
permit_params, the before_action chain, ActiveAdmin's build/create/update/save
callbacks and its controller-level authorization all apply to an MCP write
exactly as they do to a click on Save in the admin UI.

ControllerDispatcher gains a block that hands the processed controller back to
its caller. A write needs the record the controller built or loaded, and its
validation errors, and it needs them even when processing raised: a rejected
write re-renders the admin form, which is exactly the kind of render a
synthesized request does not always survive.

Resolves the permitted-params question the design left open. The fallback that
derived writable fields from a `form do ... end` block does not earn its place:
it was granting MCP clients a write ActiveAdmin does not grant, since a
resource with no permit_params raises ForbiddenAttributesError through its own
forms. It is removed, and such a resource is now refused by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lloydwatkin
lloydwatkin merged commit f6b8cc1 into main Sep 19, 2026
3 checks passed
@lloydwatkin
lloydwatkin deleted the implement-issue-8-phase-2 branch September 19, 2026 09:25
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