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
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- An `mcp_action` DSL, for exposing actions declared somewhere an `mcp:` key
cannot be added β€” a shared concern, or another gem. The resource annotates
the action by name from its own registration, so resources sharing an action
can describe it differently, and whichever does not annotate exposes nothing.
Annotations are resolved when the tool list is built rather than when they
are declared, so they may appear either side of the `include`.

Alongside `mcp:`'s own options it takes `kind:` (needed only to disambiguate
a name belonging to more than one action, which is refused rather than
guessed), `tool_name:`, and `http_verb:` (which verb to dispatch for an
action declared with several, such as `method: [:post, :delete]`). An action
may carry more than one annotation, each producing its own tool. An
annotation replaces an inline `mcp:` declaration wholesale rather than
merging into it.

A batch action declared with a String title β€” the ones applications generate
in loops from data β€” may be annotated by that title, rather than by the
symbol ActiveAdmin derives from it, which can carry punctuation.

- A `describe_form` tool, which describes the fields behind a resource's create
or update form so a client need not guess them from column names. It reads
the resource's own `form do ... end` block when it declares one β€” reporting
Expand Down Expand Up @@ -71,6 +90,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- A batch action's own ActiveAdmin `:if` proc is now honoured: one the admin UI
hides because `:if` refuses is neither listed nor runnable over MCP. This is
stricter than ActiveAdmin, which consults `:if` only when rendering and will
dispatch such an action regardless β€” deliberately so, since MCP should not be
the way round a gate the admin enforces by not offering the button. A proc
that raises, typically because it reads request state a listing cannot
supply, hides the tool and says so in the log.

- A batch action whose `form:` is a proc rather than a hash now contributes its
param types, by evaluating the proc in controller context exactly as
ActiveAdmin does. Previously proc forms were skipped and inherited nothing.
Like `suggestions:`, this runs application code, and is never evaluated for a
user the resource's authorization adapter refuses.

- Two actions that would be exposed under the same tool name are now both
hidden, with a declaration error naming the clash, rather than one silently
shadowing the other. A tool name carries no kind, so a `member_action` and a
`batch_action` of the same name derived the same one, and only the member one
was ever reachable. Give all but one an explicit `tool_name:`.

- **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
Expand Down
77 changes: 77 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,83 @@ 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.

### Actions declared somewhere you can't add `mcp:`

An action declared by a shared concern, or by another gem, has no declaration
you can hang an `mcp:` key on β€” and if it did, every resource including it
would get the same description, params and `permission:` proc. Annotate it by
name from the registration instead, with `mcp_action`:

```ruby
module Flaggable
def self.included(dsl)
dsl.send(:member_action, :flag, method: [:post, :delete]) { ... }
dsl.send(:batch_action, :flag, form: proc { { reason: :text } }) { |ids, inputs| ... }
end
end

ActiveAdmin.register Volunteer do
include Flaggable

mcp_action :flag, kind: :batch, tool_name: "volunteer_bulk_flag",
description: "Flag the selected volunteers",
params: { reason: { type: :string, required: true } }

mcp_action :flag, kind: :member, tool_name: "volunteer_flag",
description: "Flag a volunteer",
params: { reason: { type: :string, required: true } }

mcp_action :flag, kind: :member, http_verb: :delete, tool_name: "volunteer_unflag",
description: "Remove a volunteer's flag"
end
```

`mcp_action` takes everything `mcp:` takes, plus:

| Option | Meaning |
|--------|---------|
| `kind:` | `:member`, `:collection` or `:batch`. Optional; needed only when one name belongs to more than one action, which is refused rather than guessed. |
| `tool_name:` | The MCP tool name, in place of the derived `<resource>_<action>`. |
| `http_verb:` | Which verb to dispatch, for an action declared with several (`method: [:post, :delete]`). A verb the action does not answer to is a declaration error. |

It **annotates**; it never declares. Naming an action the resource does not
have warns and skips. It is resolved when the tool list is built, not when it
is called, so it may appear above or below the `include`.

An annotation **replaces** an inline `mcp:` declaration rather than merging
into it, so a shared generic declaration and a per-resource one cannot
half-combine into something neither author wrote.

An action may carry more than one annotation, each producing its own tool β€”
which is how an action answering to two verbs, one undoing the other, becomes
two tools.

**Opting in is still per resource.** Two resources including the same concern
share the actions, not the exposure: whichever does not annotate exposes
nothing.

**Names must not collide.** A tool name carries no kind, so a `member_action`
and a `batch_action` of the same name derive the same one. Rather than let one
silently shadow the other, both are hidden until a `tool_name:` tells them
apart.

**Batch actions declared with a String title** β€” the ones applications generate
in loops from data β€” may be annotated by that title, rather than by the symbol
ActiveAdmin derives from it by titleizing and underscoring, which can carry
punctuation. The derived tool name has that punctuation squeezed out.

**ActiveAdmin's `:if` proc is honoured.** A batch action the admin UI hides
because its `:if` refuses is neither listed nor runnable over MCP. ActiveAdmin
itself consults `:if` only when rendering, so this is stricter than ActiveAdmin
is β€” deliberately: MCP should not be the way round a gate the admin enforces by
not offering the button. A proc that raises, typically because it reads request
state a tool listing cannot supply, hides the tool and says so in the log.

**A proc `form:` is evaluated** in controller context, the way ActiveAdmin
evaluates it, so a batch action whose form varies by resource still contributes
its param types. Like `suggestions:`, this runs application code, and is never
evaluated for a user the resource's authorization adapter refuses.

## Connecting a client

`activeadmin_mcp` has been tested with **Claude Code** (Anthropic) over the
Expand Down
117 changes: 109 additions & 8 deletions lib/activeadmin_mcp/action_catalog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,47 @@ module ActionCatalog
RESERVED = %w[list_resources query update create describe_form].freeze

class << self
def all
ResourceRegistry.resources.flat_map { |entry| definitions_for(entry[:config]) }
# The authenticated user is carried through so a batch action's `form:`
# proc can be evaluated in controller context, the way ActiveAdmin
# evaluates it. Nothing here evaluates it β€” see ActionDefinition#params,
# which does, lazily, once the caller has authorized the action.
def all(current_user: nil)
without_colliding_names(
ResourceRegistry.resources.flat_map do |entry|
definitions_for(entry[:config], current_user)
end
)
end

def find(tool_name)
all.find { |definition| definition.tool_name == tool_name }
def find(tool_name, current_user: nil)
all(current_user: current_user).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
# A tool name carries no kind, so a member and a batch action of the same
# name derive the same one β€” which ActiveAdmin allows, and a shared
# concern declaring both is how it happens in practice. Advertising a
# duplicate would leave whichever the catalog found second permanently
# unreachable, since find returns the first match. Refuse both instead,
# and say which name, so the declaration can choose a tool_name.
def without_colliding_names(definitions)
definitions.group_by(&:tool_name).flat_map do |tool_name, sharing|
next sharing if sharing.one?

warn_and_skip("#{sharing.length} actions would both be called #{tool_name}; " \
"give all but one an explicit tool_name:")
[]
end
end

def definitions_for(config, current_user = nil)
annotated, inline = partition_declarations(config)

(annotated + inline).filter_map do |action, kind, options|
definition = ActionDefinition.new(
config: config, action: action, kind: kind, options: options, current_user: current_user
)

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)
Expand All @@ -31,6 +58,80 @@ def definitions_for(config)
end
end

# Resolves each mcp_action annotation to the action it names, and leaves
# every unannotated action to its own inline mcp: declaration. An
# annotation replaces an inline declaration wholesale rather than merging
# into it: one declaration wins, and it is visible which.
def partition_declarations(config)
actions = candidates(config)
annotated = []
claimed = []

safe_annotations(config).each do |annotation|
matches = matching_actions(actions, annotation)

next warn_and_skip(annotation_missing(config, annotation)) if matches.empty?
next warn_and_skip(annotation_ambiguous(config, annotation)) unless matches.one?

action, kind = matches.first
claimed << action
annotated << [action, kind, annotation[:options]]
end

inline = actions.filter_map do |action, kind|
# By identity, not equality: ActiveAdmin::ControllerAction is
# Comparable through a `priority` its instances do not all have, so
# asking an Array whether it includes one can raise.
next if claimed.any? { |claimed_action| claimed_action.equal?(action) }

options = action.mcp_options
[action, kind, options] if options.is_a?(Hash)
end

[annotated, inline]
end

def matching_actions(actions, annotation)
wanted = annotation[:action_name].to_s

actions.select do |action, kind|
names_of(action, kind).include?(wanted) &&
(annotation[:kind].nil? || annotation[:kind] == kind)
end
end

# A batch action may be declared with a String title, from which
# ActiveAdmin derives the symbol by titleizing, stripping spaces and
# underscoring β€” which can leave punctuation in it. Applications generate
# those in loops from data, so an annotation may name either the title it
# wrote or the symbol ActiveAdmin made of it.
def names_of(action, kind)
names = [declared_name(action, kind).to_s]
names << action.title.to_s if kind == :batch && action.respond_to?(:title)
names
end

def declared_name(action, kind)
(kind == :batch ? action.sym : action.name).to_sym
end

def annotation_missing(config, annotation)
"#{resource_name(config)} annotates #{annotation[:action_name]}, which it does not declare"
end

def annotation_ambiguous(config, annotation)
"#{resource_name(config)} annotates #{annotation[:action_name]}, which names more than one " \
"action; say which with kind:"
end

def resource_name(config)
config.resource_class.name
end

def safe_annotations(config)
config.respond_to?(:mcp_annotations) ? Array(config.mcp_annotations) : []
end

def candidates(config)
pairs = []
pairs.concat(safe_actions(config, :member_actions).map { |a| [a, :member] })
Expand Down
Loading
Loading