Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d4ba5fb
Read MCP metadata from ActiveAdmin action options
lloydwatkin Sep 18, 2026
9c089d5
Normalise opted-in ActiveAdmin actions into ActionDefinition
lloydwatkin Sep 18, 2026
0fdb00e
Fix tool_name to handle acronyms and namespaced class names
lloydwatkin Sep 18, 2026
def945c
Generate JSON Schema for opted-in ActiveAdmin actions
lloydwatkin Sep 18, 2026
87c5e40
Reject reserved param names in ActionDefinition validation
lloydwatkin Sep 18, 2026
cee0bac
Validate and coerce MCP action arguments
lloydwatkin Sep 18, 2026
90334a4
Fix ActionParams: coerce before enum check, refuse coercion failures
lloydwatkin Sep 18, 2026
2f7dcbf
Discover opted-in ActiveAdmin actions across the admin namespace
lloydwatkin Sep 18, 2026
5dfb2f1
Dispatch ActiveAdmin controller actions with the MCP user injected
lloydwatkin Sep 18, 2026
4e43ba7
Cover authorization enforcement and clean up test rows in ControllerD…
lloydwatkin Sep 18, 2026
d097bd7
Gate and run opted-in ActiveAdmin actions for MCP clients
lloydwatkin Sep 18, 2026
d8e7dc0
Cover collection permission procs and batch action record identity
lloydwatkin Sep 18, 2026
be9068d
Expose opted-in ActiveAdmin actions as MCP tools
lloydwatkin Sep 18, 2026
0190e2d
Scope batch ids, stop leaking exception text, publish the controller …
lloydwatkin Sep 18, 2026
f912b32
Authorize action tools at listing time, in controller context
lloydwatkin Sep 18, 2026
63bb87a
Refuse undroppable batch params, and say so when the readers cannot i…
lloydwatkin Sep 18, 2026
4793ab6
Document per-user tool listing, batch authorization and the auth caveat
lloydwatkin Sep 18, 2026
050ad41
Fix form-less batch actions dropping every param, not none
lloydwatkin Sep 19, 2026
d4098be
Ignore docs/
lloydwatkin Sep 19, 2026
83c7992
Drop the frozen_string_literal magic comments from the action files
lloydwatkin Sep 19, 2026
773731e
Add e2e coverage for the mcp: action opt-in, and fix CSRF on dispatch
lloydwatkin Sep 19, 2026
45ce5f2
Prove the opt-in guarantee on the call side, and write down what e2e …
lloydwatkin Sep 19, 2026
9dd3308
Cover the raising and refusing action paths, and group the e2e suite …
lloydwatkin Sep 19, 2026
4e23ee1
Fold the e2e comments into the descriptions they were explaining
lloydwatkin Sep 19, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/_yardoc/
/coverage/
/doc/
/docs/
/pkg/
/spec/reports/
/tmp/
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- 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
through the real ActiveAdmin controller, so `before_action` chains,
authorization and callbacks all apply, and an optional `permission:` proc can
narrow access further.

`tools/list` is user-specific: an action is advertised only when the
authenticated MCP user passes the resource's authorization adapter, and a
zero-argument `permission:` proc is evaluated at listing time in controller
context (so `current_admin_user` and `can?` work there as they do at call
time). A param's `suggestions:` proc, which runs application code against the
database, is never evaluated for a user who is not authorized for the action.

Batch action calls additionally run the submitted ids through the adapter's
`scope_collection` and refuse the entire call if any id falls outside it,
rather than silently acting on fewer records than the client asked for.

A batch action param declared under `mcp:` but missing from the action's
ActiveAdmin `form:` hash is now a declaration error. ActiveAdmin slices
submitted inputs to the `form:` keys, so such a param was advertised,
required and validated, and then silently dropped before the block ran.

Action failures return a generic error naming the resource and action; the
underlying exception message is written to the log instead of being sent to
the MCP client, where it could disclose SQL, table names or file paths.

### Changed

- **Breaking:** the minimum supported Ruby is now 4.0 and the minimum Rails is
Expand Down
46 changes: 46 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ over HTTP and is mounted at `/mcp` by default. The tools it offers —
`list_resources`, `query`, `update` — are defined in
`lib/activeadmin_mcp/request_handler.rb`.

## Ruby version

This gem requires Ruby 4.0 and CI runs 4.0.7. There is no `.ruby-version`, so
if your shell defaults to an older Ruby every `bundle` command fails with a
resolution error that does not mention the Ruby version as the cause. Prefix
commands with the version rather than debugging the symptom:

RBENV_VERSION=4.0.7 bundle exec rake spec
RBENV_VERSION=4.0.7 bundle exec rake e2e

## Testing MCP actions

**Every MCP action must be covered by an end-to-end test, not only by unit
Expand Down Expand Up @@ -54,6 +64,42 @@ a snapshot taken after migrating and seeding. So examples must not depend on
what another one left behind, and the suite runs in random order to keep that
honest. Write each one as though it runs alone, because it might.

### Writing an e2e example

`E2E::McpClient` speaks the protocol: `tools_list` returns the `tools/list`
result, and `call_tool(name, arguments)` unwraps both layers of a tool result
— the JSON-RPC envelope and the pretty-printed JSON inside the text content
block — and hands back the payload. A tool that refuses returns a hash with an
`"error"` key rather than raising, so assert on that key.

**Assert the side effect, not only the response.** An example that checks a
refusal came back has not shown the action was prevented: the same assertion
passes whether the call was refused before dispatch or ran and then reported
an error. Read the record back with `query` and assert it is unchanged. The
same applies in reverse for a successful call — assert the change landed, not
merely that no error came back.

**When an example is about which records were affected, assert an untouched
control record.** A batch example that only checks the selected rows changed
cannot tell "acted on the ones I asked for" from "acted on everything". Seed
or pick a record that must not change, and assert it did not.

**Prefer `include` to exact lists when asserting on the tool listing.** The
fixture application opts actions in to MCP, so the listing legitimately grows
when someone adds one; `contain_exactly` there turns an unrelated addition
into a failure in a file that has nothing to do with it.

**Environment variables set for seeding are not set for the running server.**
The seeds read credentials from the environment, but the application boots as
a separate process without them. A fixture that needs the seeded admin's
identity at request time has to hard-code it to match `AppBuilder`, not read
`ENV`.

Things this suite has caught that the unit specs structurally could not: that
Rails' forgery protection refuses the synthesized request every non-GET action
depends on, and that a `permission:` proc evaluated outside controller context
raises `NameError` and silently hides a tool. Both looked fine against mocks.

### Write e2e descriptions out in full

Give e2e examples and their enclosing blocks descriptions verbose enough that
Expand Down
89 changes: 89 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ 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. |
| *(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 Down Expand Up @@ -98,6 +99,94 @@ The `update` tool applies the same rules as the ActiveAdmin UI:
- **Permitted fields only** — attributes are filtered through the resource's
`permit_params`; fields the admin form doesn't accept are silently dropped.

### Running member, collection and batch actions

ActiveAdmin actions are **not** exposed by default. An action becomes an MCP
tool only when you add an `mcp:` option to it:

```ruby
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,
suggestions: -> { WarningCategory.pluck(:name) } }
}
} do
# your existing action body, unchanged
end
end
```

That registers a `volunteer_create_warning` tool. Batch actions opt in the same
way, and inherit their param types from the `form:` hash you already declare:

```ruby
batch_action :suspend, form: { reason: :text },
mcp: { description: "Suspend the selected volunteers" } do |ids, inputs|
# ...
end
```

**Declaring params**

- `type:` — one of `:string`, `:integer`, `:number`, `:boolean`, `:array`, `:object`.
- `required:` — refuses the call when the value is missing.
- `hint:` — static guidance shown to the agent. Always a plain string.
- `enum:` — **binding**. A value outside the list is refused before dispatch.
- `suggestions:` — a proc evaluated when tools are listed. **Advisory only**,
never enforced, so use it for live values from the database. If it raises,
the tool is still listed without suggestions.

On a batch action, every declared param must also appear in the action's `form:`
hash. ActiveAdmin slices submitted inputs down to the declared `form:` keys
before calling the block, so a param declared only under `mcp:` would be
advertised to the client and then dropped; the declaration is refused instead.

**Authorization**

`permission:` is an *additional* gate, never a replacement. Every call first
passes your ActiveAdmin authorization adapter exactly as `query` and `update`
do; the proc can only narrow access further, never widen it. It is evaluated in
controller context, so `current_admin_user`, `can?` and the usual admin helpers
are available. Return `false` to refuse, or a `String` to refuse with a reason
the agent can act on.

Tools are also listed per user: an action whose resource the adapter refuses is
left out of `tools/list` entirely, and a `permission:` proc that takes no
arguments is evaluated at listing time (in the same controller context) so the
tool is hidden rather than offered and then refused.

For **batch actions** the adapter check is necessarily resource-level — there is
no single record to authorize — so it is `authorized?(:<action>, YourModel)`
rather than a per-record policy evaluation. To stop that being a hole, the ids
the client submits are run back through the adapter's `scope_collection`, and
the whole call is refused if any of them falls outside the scope. Nothing is
narrowed silently: the call either acts on every id you asked for or on none.

One caveat on authentication. Dispatch neutralises the namespace's
`authentication_method` callback, because the MCP request has already
authenticated by bearer token and that callback would otherwise redirect to a
login page. If your `authentication_method` is a *combined* authentication-and-
authorization method — one that also, say, rejects non-superusers — then
neutralising it disables that authorization half too. Resource-level
authorization still runs through the adapter, but the "we only skip
authentication" framing is not universal; keep authorization in the adapter,
not in the authentication callback.

**What you get back**

Actions are executed through your real ActiveAdmin controller, so the action's
`before_action` chain, authorization and callbacks all run. The tool returns the
response status, the redirect target and any flash messages — not the rendered
HTML. Redirect-style (submit-side) actions are the supported case; a `GET`
action that renders a full admin view is best-effort and may fail for want of a
view context.

## Connecting a client

`activeadmin_mcp` has been tested with **Claude Code** (Anthropic) over the
Expand Down
7 changes: 7 additions & 0 deletions lib/activeadmin_mcp.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
require_relative "activeadmin_mcp/version"
require_relative "activeadmin_mcp/configuration"
require_relative "activeadmin_mcp/authorization"
require_relative "activeadmin_mcp/active_admin_ext"
require_relative "activeadmin_mcp/action_definition"
require_relative "activeadmin_mcp/action_schema"
require_relative "activeadmin_mcp/action_params"
require_relative "activeadmin_mcp/action_catalog"
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"
Expand Down
64 changes: 64 additions & 0 deletions lib/activeadmin_mcp/action_catalog.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
module ActiveadminMcp
# Finds every ActiveAdmin action an application has opted in to MCP.
#
# Nothing is cached: ActiveAdmin reloads resources in development, and a
# stale catalog would advertise tools that no longer exist.
module ActionCatalog
# Tool names the engine reserves for itself, including names Phase 2 and 3
# will take, so an application cannot silently shadow one later.
RESERVED = %w[list_resources query update create describe_form].freeze

class << self
def all
ResourceRegistry.resources.flat_map { |entry| definitions_for(entry[:config]) }
end

def find(tool_name)
all.find { |definition| definition.tool_name == tool_name }
end

private

def definitions_for(config)
candidates(config).filter_map do |action, kind|
definition = ActionDefinition.build(config: config, action: action, kind: kind)
next unless definition

next warn_and_skip(definition.errors.join("; ")) unless definition.valid?
next warn_and_skip("#{definition.tool_name} collides with a built-in tool") if reserved?(definition)

definition
end
end

def candidates(config)
pairs = []
pairs.concat(safe_actions(config, :member_actions).map { |a| [a, :member] })
pairs.concat(safe_actions(config, :collection_actions).map { |a| [a, :collection] })
pairs.concat(safe_actions(config, :batch_actions).map { |a| [a, :batch] }) if batch_enabled?(config)
pairs.select { |action, _kind| action.respond_to?(:mcp_options) }
end

def safe_actions(config, reader)
config.respond_to?(reader) ? Array(config.public_send(reader)) : []
end

# ActiveAdmin keeps registered batch actions even when the namespace has
# batch actions switched off, and hides them in the UI. Match that.
def batch_enabled?(config)
return false unless config.respond_to?(:batch_actions_enabled?)

config.batch_actions_enabled?
end

def reserved?(definition)
RESERVED.include?(definition.tool_name)
end

def warn_and_skip(message)
warn("[activeadmin_mcp] ignoring action: #{message}")
nil
end
end
end
end
Loading
Loading