Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- A `create` tool, which 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. A record the model rejects comes back as a
`Validation failed` error carrying the model's own messages.

- ActiveAdmin `member_action`, `collection_action` and `batch_action`
definitions can be exposed as MCP tools by adding an `mcp:` option to them.
Actions are opt-in: nothing is exposed without that option. Execution runs
Expand Down Expand Up @@ -42,6 +50,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Behaviour change:** `update` now dispatches the resource's real ActiveAdmin
`update` action instead of calling `record.update` directly. Everything the
admin UI runs on a save now runs on an MCP update too: the controller's
`before_action` chain, ActiveAdmin's `before_update` / `after_update` /
`before_save` / `after_save` callbacks, and the controller's own
authorization check. Applications whose callbacks have side effects —
auditing, notifications, derived columns, background jobs — will see those
fire for MCP updates where previously they were silently skipped.

Two smaller consequences of the same change: a write rejected by the model
now reports `Validation failed` with the model's messages in `details`
rather than reporting whatever `record.update` returned, and the record
echoed back in the result has the same sensitive attributes stripped from it
(`encrypted_password`, `password_digest`, `reset_password_token`, `api_key`,
`secret`) that `list_resources` and `query` already omit.

- The release tag is now the source of truth for the bundle's version too: the
release workflow writes it into `mcpb/manifest.json` and `mcpb/package.json`
before packing, and commits the bump back alongside `version.rb`. The gem and
Expand All @@ -56,6 +80,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
raising deliberately for ActiveAdmin 4. CI and the release workflow now run
on Ruby 4.0.7.

### Removed

- The fallback that derived writable fields from a resource's `form do ... end`
block when it declared no `permit_params`. Now that writes dispatch through
the real controller, ActiveAdmin resolves permitted params itself, and the
fallback turns out to have been granting MCP clients a write the admin UI
does not grant: a resource with no `permit_params` cannot be saved through
ActiveAdmin's own forms at all, because Rails raises
`ActiveModel::ForbiddenAttributesError` on the unpermitted params. Such a
resource is now refused with a message naming the missing `permit_params`,
rather than being written to.

### Security

- Enforce ActiveAdmin authorization on reads. `list_resources` and `query`
Expand Down
41 changes: 29 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ The server is a Rails engine mounted inside your application (by default at
`scope_collection`, so the MCP user only ever sees the records they could see
in the admin UI. With ActiveAdmin's default adapter every check passes, so
applications without an authorization adapter are unaffected.
- **Writes go through ActiveAdmin.** The `update` tool only writes fields
allowed by the resource's `permit_params`, refuses resources that don't
register the `update` action, and runs every change through your
authorization adapter as the authenticated MCP user.
- **Writes go through ActiveAdmin.** The `create` and `update` tools dispatch
the resource's real ActiveAdmin controller action, so `permit_params`, your
`before_save`/`after_update` callbacks, the controller's `before_action`
chain and your authorization adapter all apply exactly as they do when
someone clicks Save in the admin UI. Resources that don't register the
action are refused.
- **Authentication is optional but built in.** Enable Bearer-token auth and the
installer adds an "MCP Tokens" management page to your ActiveAdmin panel.

Expand Down Expand Up @@ -69,7 +71,8 @@ read/query setup without authentication.
|------|-------------|
| `list_resources` | List the ActiveAdmin resources the current user may read, along with their attributes. |
| `query` | Query a resource the current user may read, using Ransack syntax, scoped to the records they may access (`limit` defaults to 25, capped at 100). |
| `update` | Update an existing record, honouring ActiveAdmin's permitted params and authorization. |
| `create` | Create a new record through the resource's ActiveAdmin create action, honouring its permitted params, callbacks and authorization. |
| `update` | Update an existing record through the resource's ActiveAdmin update action, honouring its permitted params, callbacks and authorization. |
| *(per action)* | Any ActiveAdmin member, collection or batch action the application has opted in with an `mcp:` option, exposed as its own tool. |

### Query examples
Expand All @@ -82,22 +85,36 @@ Find active posts created since the start of the month
→ query(resource: "Post", q: { status_eq: "active", created_at_gt: "2026-08-01" })
```

### Updating records
### Creating and updating records

```
Create a user
→ create(resource: "User", attributes: { name: "Ada", email: "ada@example.com" })

Update a user's name
→ update(resource: "User", id: 42, attributes: { name: "New name" })
```

The `update` tool applies the same rules as the ActiveAdmin UI:
Both tools dispatch the resource's own ActiveAdmin `create` or `update`
action, so a write from MCP is the same write the admin UI makes:

- **Editable resources only** — resources registered without the `update`
action (e.g. `actions :index, :show`) are refused.
- **Authorization** — the change runs through the resource namespace's
authorization adapter for the authenticated MCP user, so it can only update
what that user is allowed to update in admin.
- **Registered actions only** — resources registered without the action
(e.g. `actions :index, :show`) are refused.
- **Authorization** — the write runs through the resource namespace's
authorization adapter for the authenticated MCP user, both before dispatch
and again inside the controller, so it can only write what that user is
allowed to write in admin.
- **Permitted fields only** — attributes are filtered through the resource's
`permit_params`; fields the admin form doesn't accept are silently dropped.
A resource that declares no `permit_params` at all is refused outright, with
a message saying so — ActiveAdmin cannot write such a resource through its
own forms either.
- **Your callbacks run** — ActiveAdmin's `before_build`, `before_create`,
`before_save`, `after_update` and friends all fire, because the controller
action is what fires them.

A write rejected by the model comes back as a `Validation failed` error with
the model's own messages in `details`, and nothing is written.

### Running member, collection and batch actions

Expand Down
3 changes: 1 addition & 2 deletions lib/activeadmin_mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@
require_relative "activeadmin_mcp/controller_dispatcher"
require_relative "activeadmin_mcp/action_runner"
require_relative "activeadmin_mcp/resource_registry"
require_relative "activeadmin_mcp/form_field_collector"
require_relative "activeadmin_mcp/record_updater"
require_relative "activeadmin_mcp/record_writer"
require_relative "activeadmin_mcp/request_handler"
require_relative "activeadmin_mcp/engine"

Expand Down
33 changes: 25 additions & 8 deletions lib/activeadmin_mcp/controller_dispatcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,33 @@ def initialize(config:, current_user:)
@current_user = current_user
end

# Yields the controller once it has finished processing, so a caller that
# needs more than the redirect — the record a write built or loaded, and
# its validation errors — can read it off the controller itself. The block
# runs whether processing succeeded or raised, because a write that fails
# its validations re-renders the form, and that render is exactly the kind
# of thing a synthesized request can blow up on. Its return value is
# ignored and an exception inside it never reaches the caller.
def call(action:, path:, verb: :get, params: {}, path_params: {})
controller = controller_with_mcp_user
request = build_request(path: path, verb: verb, params: params, action: action, path_params: path_params)
response = ActionDispatch::Response.new

controller.set_request!(request)
controller.set_response!(response)
controller.process(action)

capture(request, response)
rescue StandardError => e
# The exception text can carry internals — SQL fragments, table names,
# file paths. It belongs in the application's log, not in a tool result
# that goes to an MCP client.
warn("[activeadmin_mcp] #{@config.resource_class.name}##{action} raised #{e.class}: #{e.message}")
{ error: "#{@config.resource_class.name}##{action} failed" }
begin
controller.process(action)
capture(request, response)
rescue StandardError => e
# The exception text can carry internals — SQL fragments, table names,
# file paths. It belongs in the application's log, not in a tool result
# that goes to an MCP client.
warn("[activeadmin_mcp] #{@config.resource_class.name}##{action} raised #{e.class}: #{e.message}")
{ error: "#{@config.resource_class.name}##{action} failed" }
ensure
inspect_controller(controller, action) { |processed| yield processed } if block_given?
end
end

# A controller instance for this resource with the MCP user injected, ready
Expand Down Expand Up @@ -78,6 +89,12 @@ def current_user_methods
].select { |name| name.respond_to?(:to_sym) }.map(&:to_sym).uniq
end

def inspect_controller(controller, action)
yield controller
rescue StandardError => e
warn("[activeadmin_mcp] inspecting #{@config.resource_class.name}##{action} raised #{e.class}: #{e.message}")
end

def build_request(path:, verb:, params:, action:, path_params:)
env = Rack::MockRequest.env_for(
path,
Expand Down
46 changes: 0 additions & 46 deletions lib/activeadmin_mcp/form_field_collector.rb

This file was deleted.

106 changes: 0 additions & 106 deletions lib/activeadmin_mcp/record_updater.rb

This file was deleted.

Loading
Loading