Skip to content

Expose actions declared in shared concerns with an mcp_action DSL - #17

Merged
lloydwatkin merged 2 commits into
mainfrom
implement-mcp-action-dsl
Sep 19, 2026
Merged

lloydwatkin merged 2 commits into
mainfrom
implement-mcp-action-dsl

Conversation

@lloydwatkin

Copy link
Copy Markdown
Member

🤖 Closes #16.

Why

An action opts in to MCP by carrying an mcp: key on its own declaration. That reaches nothing declared in a shared concern or by another gem — and even where the concern can be edited, one mcp: key there would describe every resource that includes it identically, which is the opposite of what's wanted.

Such actions are already registered on the resource config as ordinary ControllerAction / BatchAction objects, so only the metadata was missing.

mcp_action

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, http_verb: :delete, tool_name: "volunteer_unflag",
    description: "Remove a volunteer's flag"
end

It annotates; it never declares. Annotations live on the resource config and are resolved when the catalog is built, so a declaration may sit either side of the include, and they are discarded with the config on a development reload. Naming an action the resource does not have warns and skips; naming one that matches more than one action without kind: is refused rather than guessed. An annotation replaces an inline mcp: wholesale rather than merging into it.

Four gaps the real concern shapes forced out

Each of these was already broken; the concerns are just what reached them.

Two actions of the same name. ActiveAdmin lets a member_action and a batch_action share a name, and a concern declaring both is how that happens. Both derived the same tool name, so tools/list advertised it twice and ActionCatalog.find always returned the member one — the batch action was permanently unreachable, silently. Both are now hidden until a tool_name: tells them apart.

method: [:post, :delete]. One action with two meanings, the second usually undoing the first, but http_verb took Array(...).first so only the first was reachable. An annotation may now pick the verb, and an action may carry several annotations, so both meanings become tools.

Batch actions titled with a String. ActiveAdmin derives the symbol by titleizing and underscoring the title, which leaves punctuation in it ("Warning: Fridge left open" → :"warning:_fridge_left_open"). Applications generate these in loops from data, so requiring the annotation to name the mangled symbol would be unusable — it may name the title instead, and the derived tool name has the punctuation squeezed out.

ActiveAdmin's :if proc. A batch action the admin UI hides because :if refuses was listed and runnable over MCP. Now neither. This is deliberately stricter than ActiveAdmin, which consults :if only when rendering and will happily dispatch an action it refuses to display — 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 logs why.

Proc form:

Evaluated in controller context exactly as ActiveAdmin evaluates it, so a batch action whose form varies by resource contributes its param types instead of nothing.

That runs application code, so it is lazy: never during catalog construction, only once the caller has authorized the action — the posture suggestions: already has. Threading the authenticated user through ActionCatalog and ActionDefinition, which were user-agnostic, is what makes that possible.

One trade-off to flag. #16 proposed recovering the "is this declared param actually among the form's keys?" check for proc forms too. That check runs during validation, at catalog-build time — the one moment the proc must not be evaluated. Rather than run application code for unauthorized users to get a declaration-error nicety, I kept the check skipped for proc forms, as it is today. Types are inherited; a mistyped param name on a proc-form batch action is still silently dropped by ActiveAdmin. Say the word if you'd rather have it the other way.

Testing

  • Unit: 242 examples. The harness gains a concern in the real shape — self.included declaring a member action with two verbs, a batch action of the same name with a proc form:, and an :if-gated batch action.
  • E2E: 63 examples. The fixture concern covers every declaration pattern in one place: same name as both member and batch, two verbs, a collection action, a proc form:, batch actions generated in a loop and titled with a String, an :if-gated batch action, and a controller before_action guarding the batch dispatch. Two resources include it and only one annotates, proving exposure stays per resource.
  • Every new e2e example was mutation-checked across three runs — removing annotations, flipping the :if proc to always admit, removing the before_action, renaming the loop titles so the annotations no longer match, dropping kind:, and breaking the member action's body. Each targeted example failed; fixtures restored and both suites re-run green.

README gains a section on annotating actions you can't edit; CHANGELOG records the addition and the three behaviour changes.

🤖 Generated with Claude Code

lloydwatkin and others added 2 commits September 19, 2026 11:24
Closes #16.

An action opts in to MCP by carrying an mcp: key on its own declaration, which
reaches nothing declared in a shared concern or by another gem. Even where the
concern could be edited, one mcp: key there would describe every resource that
includes it identically, which is the opposite of what is wanted.

Such actions are already registered on the resource config as ordinary
ControllerAction and BatchAction objects, so only the metadata was missing. The
resource now supplies it by name with mcp_action, resolved when the catalog is
built rather than when declared, so a declaration may sit either side of the
include and is discarded with the config on a reload.

Four things the concern shapes forced out, each of which was a real gap:

Two actions of the same name — ActiveAdmin allows a member and a batch action
to share one, and a concern declaring both is how that happens — derived the
same tool name. tools/list advertised it twice and find returned the member one,
leaving the batch action permanently unreachable. Both are now hidden until a
tool_name: tells them apart.

A member action declared method: [:post, :delete] is one action with two
meanings, the second usually undoing the first, but only the first verb was
reachable. An annotation may now pick the verb, and an action may carry several
annotations, so both meanings become tools.

A batch action titled with a String gets a symbol ActiveAdmin derives by
mangling that title, punctuation included. Applications generate these in loops
from data, so an annotation may name the title instead, and the derived tool
name has the punctuation squeezed out.

ActiveAdmin's own :if proc on a batch action is now honoured. ActiveAdmin
consults it only when rendering, so it will dispatch an action it refuses to
display; this is deliberately stricter, because MCP should not be the way round
a gate the admin enforces by not offering the button.

A proc form: is evaluated in controller context the way ActiveAdmin evaluates
it, so those batch actions contribute their param types. That runs application
code, so it is lazy: never during catalog construction, only once the caller
has authorized the action, which is the posture suggestions: already has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lloydwatkin
lloydwatkin merged commit 3e82e81 into main Sep 19, 2026
3 checks passed
@lloydwatkin
lloydwatkin deleted the implement-mcp-action-dsl branch September 19, 2026 10:44
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.

Expose actions declared in shared concerns via an mcp_action DSL

1 participant